Table of Contents
简介¶
开发知识wiki站点。
操作系统 ↵
IO¶
Page Cache¶
在现代计算机系统中,CPU,RAM,DISK的速度不相同,按速度高低排列 为:CPU>RAM>DISK。CPU与RAM之间、RAM与DISK之间的速度差异常常是指数级。同时,它们之间的处理容量也不相同,其差异也是指数级。
为了在速度和容量上折中,在CPU与RAM之间使用CPU cache以提高访存速度,在RAM与磁盘之间,操作系统使用page cache提高系统对文件的访问速度。
Page cache是通过将磁盘中的数据缓存到内存中,从而减少磁盘I/O操作,从而提高性能。此外还要确保在page cache中的数据更改时能够被同步到磁盘上,后者被称为page回写(page writeback)。page回写往往不会立即执行,这样好处是可以减少磁盘的回写次数,提高吞吐量,不足之处就死机器挂掉,page cache中数据就会丢失。一个inode关联一个page cahce, 一个page cache对象包含多个物理page。
当应用程序需要读取文件中的数据时,操作系统先分配一些内存,将数据从存储设备读入到这些内存中,然后再将数据分发给应用程序;当需要往文件中写数据时,操作系统先分配内存接收用户数据,然后再将数据从内存写到磁盘上。文件Cache管理指的就是对这些由操作系统内核分配,并用来存储文件数据的内存管理。
在大部分情况下,内核在读写磁盘时都先通过页面Cache。若页面不在Cache中,新页加入到页面Cache中,并用从磁盘上读来的数据来填充页面。如果内存有足够的内存空间,该页可以在页面Cache长时间驻留,其他进程再访问该部分数据时,不需要访问磁盘。这就是free命令显示内核free值越来越小,cached值越来越大的原因。
同样,在把一页数据写到块设备之前,内核首先检查对应的页是否已经在页面Cache中;如果不在,就在页面Cache增加一个新页面,并用要写到磁盘的数据来填充。数据的I/O传输并不会立即开始执行,而是会延迟几秒左右;这样进程就有机会进一步修改写到磁盘的数据
对于系统的所有文件I/O请求,操作系统都是通过page cache机制实现的.对于操作系统而言,磁盘文件都是由一系列的数据块顺序组成,数据块的大小随系统不同而不同,x86 linux系统下是4KB(一个标准页面大小)。内核在处理文件I/O请求时,首先到page cache中查找(page cache中的每一个数据块都设置了文件以及偏移信息),如果未命中,则启动磁盘I/O,将磁盘文件中的数据块加载到page cache中的一个空闲块,之后再copy到用户缓冲区中。
页面Cache可能是下面的类型:
- 含有普通文件数据的页
- 含有目录的页
- 含有直接从块设备文件(跳过文件系统层)读出的数据页
- 含有用户态进程数据的页,但页中的数据已被交换到磁盘
- 属于特殊文件系统的页,如进程间通信中的特殊文件系统shm
页面Cache中的每页所包含的数据是属于某个文件,这个文件(准确地说是文件的inode)就是该页的拥有者。事实上,所有的read()和write()都依赖于页面Cache;唯一的例外是当进程打开文件时,使用了O_DIRECT标志,在这种情况下,页面Cache被跳过,且使用了进程用户态地址空间的缓冲区。有些数据库应用程序使用O_DIRECT标志,这样他们可以使用自己的磁盘缓冲算法。
从硬盘读取文件时,同样不是直接把硬盘上文件内容读取到用户态内存,而是先拷贝到内核的page cache,然后再“拷贝”到用户态内存,这样用户就可以访问该文件。因为涉及到硬盘操作,所以第一次读取一个文件时,不会有性能提升;不过,如果一个文件已经存在page cache中,再次读取该文件时就可以直接从page cache中命中读取不涉及硬盘操作,这时性能就会有很大提高。
下面用dd比较下异步(缺省模式)和同步写硬盘的速度差别:
$ dd if=/dev/urandom of=async.txt bs=64M count=16 iflag=fullblock
16+0 records in
16+0 records out
1073741824 bytes (1.1 GB, 1.0 GiB) copied, 7.618 s, 141 MB/s
$ dd if=/dev/urandom of=sync.txt bs=64M count=16 iflag=fullblock oflag=sync
16+0 records in
16+0 records out
1073741824 bytes (1.1 GB, 1.0 GiB) copied, 13.2175 s, 81.2 MB/s
如何查看一个文件占用page cache情况?
我们可以借助vmtouch工具
Cache同步方式¶
Cache的同步方式有两种,即**Write Through(写穿)** 和 Write back(写回) 。
对应到Linux的Page Cache上所谓Write Through就是指write(2)操作将数据拷贝到Page Cache后立即和下层进行同步的写操作,完成下层的更新后才返回,可以理解为写穿透page cache直抵磁盘。
而Write back正好相反,指的是写完Page Cache就可以返回了,可以理解为写到page cache就返回了。Page Cache到下层的更新操作是异步进行的。
Linux下Buffered IO默认使用的是Write back机制,即文件操作的写只写到Page Cache就返回,之后Page Cache到磁盘的更新操作是异步进行的。Page Cache中被修改的内存页称之为脏页(Dirty Page),脏页在特定的时候被一个叫做pdflush(Page Dirty Flush)的内核线程写入磁盘,写入的时机和条件如下:
当空闲内存低于一个特定的阈值时,内核必须将脏页写回磁盘,以便释放内存。 当脏页在内存中驻留时间超过一个特定的阈值时,内核必须将超时的脏页写回磁盘。 用户进程调用sync(2)、fsync(2)、fdatasync(2)系统调用时,内核会执行相应的写回操作。
如果程序crash,异步模式(write back)会丢失数据吗?
如果OS没有crash或者重启的话,仅仅是写数据的程序crash,那么已经成功写入到page cache中的dirty pages是会被pdflush在合适的时机被写回到硬盘,不会丢失数据; 如果OS也crash或者重启的话,因为page cache存放在内存中,一旦断电就丢失了,那么就会丢失数据。
那么如何避免因为系统重启或者机器突然断电,导致数据丢失问题呢?
可以借助于WAL(Write-Ahead Log)技术。WAL技术在数据库系统中比较常见,在数据库中一般又称之为redo log,Linux 文件系统ext3/ext4称之为journaling。WAL作用是:写数据库或者文件系统前,先把相关的metadata和文件内容写入到WAL日志中,然后才真正写数据库或者文件系统。WAL日志是append模式,所以,对WAL日志的操作要比对数据库或者文件系统的操作轻量级得多。如果对WAL日志采用同步写模式,那么WAL日志写成功,即使写数据库或者文件系统失败,可以用WAL日志来恢复数据库或者文件系统里的文件。
mmap - Memory Map¶
同一块文件数据,在内存中保存了两份(内核必须将页面缓存的内容复制到用户缓冲区中),这既占用了不必要的内存空间、冗余的拷贝、以及造成的CPU cache利用率不高。针对此问题,操作系统提供了内存映射机制(linux中mmap、windows中Filemapping)。如下图:
当使用文件映射时,内核将程序的虚拟页面直接映射到页面缓存中。在使用mmap调用时,系统并不是马上为其分配内存空间,而仅仅是添加一个VMA到该进程中,当程序访问到目标空间时,产生**缺页中断**。在缺页中断中,从page caches中查找要访问的文件块,若未命中,则启动磁盘I/O从磁盘中加载到page caches。然后将文件块在page caches中的物理页映射到进程mmap地址空间。
当程序退出或关闭文件时,系统是否会马上清除page caches中的相应页面呢?
答案是否定的。由于该文件可能被其他进程访问,或该进程一段时间后会重新访问,因此,在物理内存足够的情况下,系统总是将其保持在page caches中,这样可以提高系统的整体性能(提高page caches的命中率,尽量少的访问磁盘)。只有当系统物理内存不足时,内核才会主动清理page caches。
当进程调用write修改文件时,由于page cache的存在,修改并不是马上更新到磁盘,而只是暂时更新到page caches中,同时mark 目标page为dirty,当内核主动释放page caches时,才将更新写入磁盘(主动调用sync时,也会更新到磁盘)。
内存映射文件的写入不一定是对磁盘文件的即时(同步)写入。有的操作系统定期检查文件的内存映射页面是否已被修改,以便选择是否更新到物理文件。当关闭文件时,所有内存映射的数据会写到磁盘,并从进程虚拟内存中删除。
多个进程可以允许并发地内存映射同一文件,以便允许数据共享。任何一个进程的写入会修改虚拟内存的数据,并且其他映射同一文件部分的进程都可看到。
虚拟映射只支持文件。我们可以通过/proc/<pid>/maps查看进行pid的mmap文件。
Zero Copy¶
零拷贝(Zero Copy)技术是直接从内核空间(DMA的)到内核空间(Socket的)、然后发送网卡。
传统的网络I/O操作流程,大体上分为以下4步:
- OS从硬盘把数据读到内核区的PageCache。
- 用户进程把数据从内核区Copy到用户区。
- 然后用户进程再把数据写入到Socket,数据流入内核区的Socket Buffer上。
- OS再把数据从Buffer中Copy到网卡的Buffer上,这样完成一次发送。
从上图可以看出,传统网络IO会历经两次Context Switch,四次数据拷贝。实际上IO读写,需要进行IO中断,需要CPU响应中断(带来上下文切换),尽管后来引入DMA来接管CPU的中断请求,但四次copy是存在“不必要的拷贝”的。
同一份数据在内核buffer与用户buffer之间重复拷贝,效率低下。其中2,3两步没有必要,完全可以直接在内核空间完成数据拷贝。这也是sendfile所解决的问题,经过sendfile优化后,整个I/O过程变成了下面的样子:
从上图可以看出,通过sendfile 系统调用,提供了零拷贝。磁盘数据通过 DMA 拷贝到内核态 Buffer 后,直接通过 DMA 拷贝到 NIC Buffer(socket buffer),无需 CPU 拷贝,所以称为零拷贝。除了减少数据拷贝外,因为整个读文件 - 网络发送由一个 sendfile 调用完成,整个过程只有两次上下文切换,因此大大提高了性能。
零拷贝应用场景¶
-
如Tomcat、Nginx、Apache等web服务器返回静态资源等,将数据用网络发送出去,都运用了sendfile
-
Kafka中的Consumer从broker中获取消息时候,broker使用到了sendfile
mmap 和 sendfile比较¶
-
都是Linux内核提供、实现零拷贝的API
-
sendfile 是将读到内核空间的数据,转到socket buffer,进行网络发送
-
mmap将磁盘文件映射到内存,支持读和写,对内存的操作会反映在磁盘文件上
什么是DMA?
本质上,DMA技术就是我们在主板上放⼀块独立的芯片。在进行内存和I/O设备的数据传输的时候,我们不再通过CPU来控制数据传输,而直接通过 DMA控制器(DMA?Controller,简称DMAC)。这块芯片,我们可以认为它其实就是一个协处理器(Co-Processor))
IO模式¶
1.1 用户空间和内核空间¶
现在操作系统都采用虚拟寻址,处理器先产生一个虚拟地址,通过地址翻译成物理地址(内存的地址),再通过总线的传递,最后处理器拿到某个物理地址返回的字节。
对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方)。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。针对linux操作系统而言,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为内核空间,而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为用户空间。
补充:地址空间就是一个非负整数地址的有序集合。如{0,1,2...}。
1.2 进程上下文切换(进程切换)¶
为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换(也叫调度)。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。
从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化:
-
保存当前进程A的上下文
上下文就是内核再次唤醒当前进程时所需要的状态,由一些对象(程序计数器、状态寄存器、用户栈等各种内核数据结构)的值组成。
这些值包括描绘地址空间的页表、包含进程相关信息的进程表、文件表等。
-
切换页全局目录以安装一个新的地址空间。
-
恢复进程B的上下文。
可以理解成一个比较耗资源的过程。
1.3 进程的阻塞¶
正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得CPU),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用CPU资源的。
1.4 文件描述符¶
文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。
文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。
1.5 直接I/O和缓存I/O¶
缓存I/O 又被称作标准 I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。在 Linux 的缓存 I/O 机制中,以write为例,数据会先被拷贝进程缓冲区,在拷贝到操作系统内核的缓冲区中,然后才会写到存储设备中。
直接I/O的write:(少了拷贝到进程缓冲区这一步)
write过程中会有很多次拷贝,直到数据全部写到磁盘。
I/O模式¶
对于一次IO访问(这回以read举例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的缓冲区,最后交给进程。所以说,当一个read操作发生时,它会经历两个阶段: 1. 等待数据准备 (Waiting for the data to be ready) 2. 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)
正式因为这两个阶段,linux系统产生了下面五种网络模式的方案:
- 阻塞 I/O(blocking IO)
- 非阻塞 I/O(nonblocking IO)
- I/O 多路复用( IO multiplexing)
- 信号驱动 I/O( signal driven IO)
- 异步 I/O(asynchronous IO)
block I/O模型(阻塞I/O)¶
阻塞I/O模型示意图:
read为例:
(1)进程发起read,进行recvfrom系统调用;
(2)内核开始第一阶段,准备数据(从磁盘拷贝到缓冲区),进程请求的数据并不是一下就能准备好;准备数据是要消耗时间的;
(3)与此同时,进程阻塞(进程是自己选择阻塞与否),等待数据ing;
(4)直到数据从内核拷贝到了用户空间,内核返回结果,进程解除阻塞。
也就是说,内核准备数据和数据从内核拷贝到进程内存地址这两个过程都是阻塞的。
2.2 non-block(非阻塞I/O模型)¶
可以通过设置socket使其变为non-blocking。当对一个non-blocking socket执行读操作时,流程是这个样子:
-
当用户进程发出read操作时,如果kernel中的数据还没有准备好;
-
那么它并不会block用户进程,而是立刻返回一个error,从用户进程角度讲 ,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果;
-
用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call;
-
那么它马上就将数据拷贝到了用户内存,然后返回。
所以,nonblocking IO的特点是用户进程在内核准备数据的阶段需要不断的主动询问数据好了没有。
2.3 I/O多路复用¶
I/O多路复用实际上就是用select, poll, epoll监听多个io对象,当io对象有变化(有数据)的时候就通知用户进程。好处就是单个进程可以处理多个socket。当然具体区别我们后面再讨论,现在先来看下I/O多路复用的流程:
-
当用户进程调用了select,那么整个进程会被block;
-
而同时,kernel会“监视”所有select负责的socket;
-
当任何一个socket中的数据准备好了,select就会返回;
-
这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。 所以,I/O 多路复用的特点是通过一种机制一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,select()函数就可以返回。
这个图和blocking IO的图其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个system call (select 和 recvfrom),而blocking IO只调用了一个system call (recvfrom)。但是,用select的优势在于它可以同时处理多个connection。
所以,如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用多线程 + 阻塞 IO的web server性能更好,可能延迟还更大。
select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。)
在IO multiplexing Model中,实际中,对于每一个socket,一般都设置成为non-blocking,但是,如上图所示,整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket IO给block。
select & poll & epoll比较:
- 每次调用 select 都需要把所有要监听的文件描述符拷贝到内核空间一次,fd很大时开销会很大。 epoll 会在epoll_ctl()中注册,只需要将所有的fd拷贝到内核事件表一次,不用再每次epoll_wait()时重复拷贝
-
每次 select 需要在内核中遍历所有监听的fd,直到设备就绪; epoll 通过 epoll_ctl 注册回调函数,也需要不断调用 epoll_wait 轮询就绪链表,当fd或者事件就绪时,会调用回调函数,将就绪结果加入到就绪链表。
-
select 能监听的文件描述符数量有限,默认是1024; epoll 能支持的fd数量是最大可以打开文件的数目,具体数目可以在/proc/sys/fs/file-max查看 select , poll 在函数返回后需要查看所有监听的fd,看哪些就绪,而**epoll只返回就绪的描述符**,所以应用程序只需要就绪fd的命中率是百分百。
2.4 信号驱动IO¶
当进程发起一个IO操作,会向内核注册一个信号处理函数,然后进程返回不阻塞;当内核数据就绪时会发送一个信号给进程,进程便在信号处理函数中调用IO读取数据。
异步 I/O 与信号驱动 I/O 的区别在于,异步 I/O 的信号是通知应用进程 I/O 完成,而信号驱动 I/O 的信号是通知应用进程可以开始 I/O。
2.5 asynchronous I/O(异步 I/O)¶
-
用户进程发起read操作之后,立刻就可以开始去做其它的事。
-
而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。
-
然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。
小结¶
- blocking和non-blocking的区别
调用blocking IO会一直block住对应的进程直到操作完成,而non-blocking IO在kernel还准备数据的情况下会立刻返回。
-
synchronous IO和asynchronous IO的区别 在说明synchronous IO和asynchronous IO的区别之前,需要先给出两者的定义。POSIX的定义是这样子的:
-
A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes;
- An asynchronous I/O operation does not cause the requesting process to be blocked;
两者的区别就在于synchronous IO做”IO operation”的时候会将process阻塞。按照这个定义,之前所述的blocking IO,non-blocking IO,IO multiplexing都属于synchronous IO。
有人会说,non-blocking IO并没有被block啊。这里有个非常“狡猾”的地方,定义中所指的”IO operation”是指真实的IO操作,就是例子中的recvfrom这个system call。non-blocking IO在执行recvfrom这个system call的时候,如果kernel的数据没有准备好,这时候不会block进程。但是,当kernel中数据准备好的时候,recvfrom会将数据从kernel拷贝到用户内存中,这个时候进程是被block了,在这段时间内,进程是被block的。
而asynchronous IO则不一样,当进程发起IO 操作之后,就直接返回再也不理睬了,直到kernel发送一个信号,告诉进程说IO完成。在这整个过程中,进程完全没有被block。
- non-blocking IO和asynchronous IO的区别
可以发现non-blocking IO和asynchronous IO的区别还是很明显的。
-
在non-blocking IO中,虽然进程大部分时间都不会被block,但是它仍然要求进程去主动的check,并且当数据准备完成以后,也需要进程主动的再次调用recvfrom来将数据拷贝到用户内存。
-
而asynchronous IO则完全不同。它就像是用户进程将整个IO操作交给了他人(kernel)完成,然后他人做完后发信号通知。在此期间,用户进程不需要去检查IO操作的状态,也不需要主动的去拷贝数据。
Epoll模式¶
Epoll支持的两种操作模式¶
epoll对文件描述符有两种操作模式
-
LT(Level Trigger水平模式)
LT是epoll的默认操作模式,当epoll_wait函数检测到有事件发生并将通知应用程序,而应用程序不一定必须立即进行处理,这样epoll_wait函数再次检测到此事件的时候还会通知应用程序,直到事件被处理。LT支持阻塞的套接字和非阻塞的套接字。
-
ET(Edge Trigger边缘模式)
ET模式下,只要epoll_wait函数检测到事件发生,通知应用程序立即进行处理,后续的epoll_wait函数将不再检测此事件。因此ET模式在很大程度上降低了同一个事件被epoll触发的次数,因此效率比LT模式高。ET只支持非阻塞的套接字。
ET是状态变化的通知,即从没有数据转到有数据会通知,LT是数据变化的通知,即有数据就通知,没数据就不通知。对于ET模式,当接收到通知后,应该一直read循环读取,直到返回EWOULDBLOCK或EAGAIN,这样内部状态才会从有数据再次转为无数据,从而为下一次数据的到来做准备,否则只有对端再次发送数据时候,才会再次触发可读事件。
对于ET状态应该注意防止恶意请求连接,防止其一直请求,造成其他请求饿死。
资料¶
proc文件系统
proc文件系统¶
Linux系统上的/proc目录是一种文件系统,即proc文件系统。与其它常见的文件系统不同的是,/proc是一种伪文件系统(也即虚拟文件系统),存储的是当前内核运行状态的一系列特殊文件,用户可以通过这些文件查看有关系统硬件及当前正在运行进程的信息,甚至可以通过更改其中某些文件来改变内核的运行状态。
/proc目录下文件¶
-
/proc/buddyinfo
用于诊断内存碎片问题的相关信息文件
-
/proc/cmdline
在启动时传递至内核的相关参数信息,这些信息通常由lilo或grub等启动管理工具进行传递
-
/proc/cpuinfo
处理器的相关信息的文件
# 总核数 = 物理CPU个数 X 每颗物理CPU的核数
# 总逻辑CPU数 = 物理CPU个数 X 每颗物理CPU的核数 X 超线程数
# 查看物理CPU个数
cat /proc/cpuinfo| grep "physical id"| sort| uniq| wc -l
# 查看每个物理CPU中core的个数(即核数)
cat /proc/cpuinfo| grep "cpu cores"| uniq
# 查看逻辑CPU的个数
cat /proc/cpuinfo| grep "processor"| wc -l
# 查看CPU信息(型号)
cat /proc/cpuinfo | grep name | cut -f2 -d: | uniq -c
-
/proc/crypto
系统上已安装的内核使用的密码算法及每个算法的详细信息列表
-
/proc/devices
系统已经加载的所有块设备和字符设备的信息,包含主设备号和设备组(与主设备号对应的设备类型)名
-
/proc/diskstats
每块磁盘设备的磁盘I/O统计信息列表;(内核2.5.69以后的版本支持此功能)
-
/proc/dma
每个正在使用且注册的ISA DMA通道的信息列表
-
/proc/execdomains
内核当前支持的执行域(每种操作系统独特“个性”)信息列表
-
/proc/fb
帧缓冲设备列表文件,包含帧缓冲设备的设备号和相关驱动信息
-
/proc/filesystems
当前被内核支持的文件系统类型列表文件,被标示为nodev的文件系统表示不需要块设备的支持;通常mount一个设备时,如果没有指定文件系统类型将通过此文件来决定其所需文件系统的类型
-
/proc/interrupts
X86或X86_64体系架构系统上每个IRQ相关的中断号列表;多路处理器平台上每个CPU对于每个I/O设备均有自己的中断号
-
/proc/iomem
每个物理设备上的记忆体(RAM或者ROM)在系统内存中的映射信息
-
/proc/ioports
当前正在使用且已经注册过的与物理设备进行通讯的输入-输出端口范围信息列表;如下面所示,第一列表示注册的I/O端口范围,其后表示相关的设备
-
/proc/kallsyms
模块管理工具用来动态链接或绑定可装载模块的符号定义,由内核输出;(内核2.5.71以后的版本支持此功能);通常这个文件中的信息量相当大
-
/proc/kcore
系统使用的物理内存,以ELF核心文件(core file)格式存储,其文件大小为已使用的物理内存(RAM)加上4KB;这个文件用来检查内核数据结构的当前状态,因此,通常由GBD通常调试工具使用,但不能使用文件查看命令打开此文件
-
/proc/kmsg
此文件用来保存由内核输出的信息,通常由/sbin/klogd或/bin/dmsg等程序使用,不要试图使用查看命令打开此文件
-
/proc/loadavg
保存关于CPU和磁盘I/O的负载平均值,其前三列分别表示每1秒钟、每5秒钟及每15秒的负载平均值,类似于uptime命令输出的相关信息;第四列是由斜线隔开的两个数值,前者表示当前正由内核调度的实体(进程和线程)的数目,后者表示系统当前存活的内核调度实体的数目;第五列表示此文件被查看前最近一个由内核创建的进程的PID
-
/proc/locks
保存当前由内核锁定的文件的相关信息,包含内核内部的调试数据;每个锁定占据一行,且具有一个惟一的编号;如下输出信息中每行的第二列表示当前锁定使用的锁定类别,POSIX表示目前较新类型的文件锁,由lockf系统调用产生,FLOCK是传统的UNIX文件锁,由flock系统调用产生;第三列也通常由两种类型,ADVISORY表示不允许其他用户锁定此文件,但允许读取,MANDATORY表示此文件锁定期间不允许其他用户任何形式的访问
-
/proc/mdstat
保存RAID相关的多块磁盘的当前状态信息,在没有使用RAID机器上,其显示为如下状态
- /proc/meminfo系统中关于当前内存的利用状况等的信息,常由free命令使用;可以使用文件查看命令直接读取此文件,其内容显示为两列,前者为统计属性,后者为对应的值
-
/proc/mounts
在内核2.4.29版本以前,此文件的内容为系统当前挂载的所有文件系统,在2.4.19以后的内核中引进了每个进程使用独立挂载名称空间的方式,此文件则随之变成了指向/proc/self/mounts(每个进程自身挂载名称空间中的所有挂载点列表)文件的符号链接;/proc/self是一个独特的目录
-
/proc/modules
当前装入内核的所有模块名称列表,可以由lsmod命令使用,也可以直接查看;如下所示,其中第一列表示模块名,第二列表示此模块占用内存空间大小,第三列表示此模块有多少实例被装入,第四列表示此模块依赖于其它哪些模块,第五列表示此模块的装载状态(Live:已经装入;Loading:正在装入;Unloading:正在卸载),第六列表示此模块在内核内存(kernel memory)中的偏移量
-
/proc/partitions
块设备每个分区的主设备号(major)和次设备号(minor)等信息,同时包括每个分区所包含的块(block)数目
-
/proc/pci
内核初始化时发现的所有PCI设备及其配置信息列表,其配置信息多为某PCI设备相关IRQ信息,可读性不高,可以用“/sbin/lspci –vb”命令获得较易理解的相关信息;在2.6内核以后,此文件已为/proc/bus/pci目录及其下的文件代替
-
/proc/slabinfo
在内核中频繁使用的对象(如inode、dentry等)都有自己的cache,即slab pool,而/proc/slabinfo文件列出了这些对象相关slap的信息;详情可以参见内核文档中slapinfo的手册页
-
/proc/stat
实时追踪自系统上次启动以来的多种统计信息:
- cpu”行后的八个值分别表示以1/100(jiffies)秒为单位的统计值(包括系统运行于用户模式、低优先级用户模式,运系统模式、空闲模式、I/O等待模式的时间等); - intr:行给出中断的信息,第一个为自系统启动以来,发生的所有的中断的次数;然后每个数对应一个特定的中断自系统启动以来所发生的次数 - ctxt:给出了自系统启动以来CPU发生的上下文交换的次数 - btime:给出了从系统启动到现在为止的时间,单位为秒 - processes (total_forks) 自系统启动以来所创建的任务的个数目; - procs_running:当前运行队列的任务的数目 - procs_blocked:当前被阻塞的任务的数目 -
/proc/swaps
当前系统上的交换分区及其空间利用信息,如果有多个交换分区的话,则会每个交换分区的信息分别存储于/proc/swap目录中的单独文件中,而其优先级数字越低,被使用到的可能性越大
-
/proc/uptime
系统上次启动以来的运行时间,如下所示,其第一个数字表示系统运行时间,第二个数字表示系统空闲时间,单位是秒
-
/proc/version
当前系统运行的内核版本号
-
/proc/vmstat
当前系统虚拟内存的多种统计数据,信息量可能会比较大,这因系统而有所不同,可读性较好
-
/proc/zoneinfo
内存区域(zone)的详细信息列表,信息量较大
/proc/sys目录下文件¶
与 /proc下其它文件的“只读”属性不同的是,管理员可对/proc/sys子目录中的许多文件内容进行修改以更改内核的运行特性,事先可以使用“ls -l”命令查看某文件是否“可写入”。写入操作通常使用类似于“echo DATA > /path/to/your/filename”的格式进行。需要注意的是,即使文件可写,其一般也不可以使用编辑器进行编辑
- /proc/sys/debug 是子目录,此目录通常是一空目录
- /proc/sys/dev 是子目录,为系统上特殊设备提供参数信息文件的目录,其不同设备的信息文件分别存储于不同的子目录中
/proc/pid目录下文件¶
-
cmdline
启动当前进程的完整命令,但僵尸进程目录中的此文件不包含任何信息 - cwd
指向当前进程运行目录的一个符号链接
-
environ
当前进程的环境变量列表,彼此间用空字符(NULL)隔开;变量用大写字母表示,其值用小写字母表示
-
exe
指向启动当前进程的可执行文件(完整路径)的符号链接,通过/proc/N/exe可以启动当前进程的一个拷贝。其中/proc/self/exe,它代表当前程序的符号链接。
-
fd
这是个目录,包含当前进程打开的每一个文件的文件描述符(file descriptor),这些文件描述符是指向实际文件的一个符号链接
-
limits
当前进程所使用的每一个受限资源的软限制、硬限制和管理单元;此文件仅可由实际启动当前进程的UID用户读取
-
maps
当前进程关联到的每个可执行文件和库文件在内存中的映射区域及其访问权限所组成的列表
-
mem
当前进程所占用的内存空间,由open、read和lseek等系统调用使用,不能被用户读取;
-
root
指向当前进程运行根目录的符号链接;在Unix和Linux系统上,通常采用chroot命令使每个进程运行于独立的根目录
-
stat
当前进程的状态信息,包含一系统格式化后的数据列,可读性差,通常由ps命令使用
-
statm
当前进程占用内存的状态信息,通常以“页面”(page)表示
-
status
与stat所提供信息类似,但可读性较好,如下所示,每行表示一个属性信息;其详细介绍请参见 proc的man手册页
-
task
目录文件,包含由当前进程所运行的每一个线程的相关信息,每个线程的相关信息文件均保存在一个由线程号(tid)命名的目录中,这类似于其内容类似于每个进程目录中的内容
-
cgroup cgroup信息
- mountinfo mountinfo信息,具体格式说明参见:https://man7.org/linux/man-pages/man5/proc.5.html
需要特别注意的是/proc/self是magic symbolic link,当进程访问时候,它会对应解析到进程自己的/proc/[pid]目录。
资料¶
NPTL¶
简介¶
在内核2.6以前的调度实体都是进程,内核并没有真正支持线程。它是通过系统调用clone()来实现的,在调用时候传递CLONE_VM这个标志位,这样新创建的进程(线程)会和当前进程共享进程地址空间,这种实现方式就是LinuxThread。我们创建新进程时候调用的fork函数,最后也是调用的clone()这个系统调用,只不过没有传递CLONE_VM这个标志位,所以fork创建的新进程和当前进程拥有的是两个不同的内存地址空间。
通过LinuxThread方式实现的多线程,并没有遵循没有遵循POSIX标准,特别是在信号处理,调度,进程间通信原语等方面。比如当给一个进程发送信号时候,由于该进程对应的线程自身拥有独立的进程ID,而无法进行响应。LinuxThread的内核对应的管理实体是进程,也称称LWP(轻量级进程),每个线程的pid是不一样的。
为了改进LinuxThread,NTPL(Native POSIX Threads Library)应运而生。
NPTL使用了跟LinuxThread相同的办法,在内核里面线程仍然被当作是一个进程,并且仍然使用了clone()系统调用(在NPTL库里调用)。但是,NPTL需要内核级的特殊支持来实现,比如需要挂起然后再唤醒线程的线程同步原语futex。
NPTL是一个1*1的线程库,就是说当你使用pthread_create()调用创建一个线程后,在内核里就相应创建了一个调度实体,在linux里就是一个新进程,这个方法最大可能的简化了线程的实现。这种模式属于系统级线程。除此之外NPTL还支持m*n模型。
NPTL¶
创建线程¶
Linux内核中无论是进程还是线程,其底层数据结构都是task_struct。
NPTL为了方便管理线程,引入了线程组的概念,来实现同一组线程具有相同的PID。为此在task_struct结构体中增加了tgid字段来记录组PID。在线程组中的所有线程的tgid字段都指向线程组长(也可称为领头线程的)的PID。在线程中调用getpid()时候返回的是tgid,而不是当前线程的pid。
NPTL创建线程时候也是使用clone系统调用,只不过传递flag参数设置了标志位CLONE_THREAD。
同步方式¶
内核增加一个新的互斥同步原语futex(fast usesapace locking system call),意为快速用户空间系统锁。因为进程内的所有线程都使用了相同的内存空间,所以这个锁可以保存在用户空间。这样对这个锁的操作不需要每次都切换到内核态,从而大大加快了存取的速度。NPTL提供的线程同步互斥机制都建立在futex上,所以无论在效率上还是咋对程序的外部影响上都比LinuxThread的方式有了很大的改进。
信号处理¶
因为同一个进程内的线程都属于同一个进程,所以信号处理跟POSIX标准完全统一。当你发送一个SIGSTP信号给进程,这个进程的所有线程都会停止。因为所有线程内用同样的内存空间,所以对一个signal的handler都是一样的,但不同的线程有不同的管理结构所以不同的线程可以有不同的mask。后面这一段对LinuxThread也成立。信号处理总结:
-
默认情况下,信号将由主进程接收处理,就算信号处理函数是由子线程注册的
-
Linux 多线程应用中,每个线程可以通过调用pthread_sigmask() 设置本线程的信号掩码。一般情况下,被阻塞的信号将不能中断此线程的执行,除非此信号的产生是因为程序运行出错如SIGSEGV;另外不能被忽略处理的信号SIGKILL 和SIGSTOP 也无法被阻塞。
-
当一个线程调用pthread_create() 创建新的线程时,此线程的信号掩码会被新创建的线程继承。
-
可以使用pthread_kill对指定的线程发送信号
-
忽略信号不同于阻塞信号,忽略信号是指Linux内核已经向应用程序交付了产生的信号,只是应用程序直接丢弃了该信号而已。
-
sigprocmask函数只能用于单线程,在多线程中使用pthread_sigmask函数。
-
信号是发给进程的特殊消息,其典型特性是具有异步性。
进一步阅读¶
容器 ↵
Docker 安装和基础用法¶
Docker 的基本操作¶
Docker 容器的状态机¶
一个容器在某个时刻可能处于以下几种状态之一:
- created:已经被创建 (使用 docker ps -a 命令可以列出)但是还没有被启动 (使用 docker ps 命令还无法列出)
- running:运行中
- paused:容器的进程被暂停了
- restarting:容器的进程正在重启过程中
- exited:上图中的 stopped 状态,表示容器之前运行过但是现在处于停止状态(要区别于 created 状态,它是指一个新创出的尚未运行过的容器)。可以通过 start 命令使其重新进入 running 状态
- destroyed:容器被删除了,再也不存在了
你可以在 docker inspect 命令的输出中查看其详细状态:
"State": {
"Status": "running",
"Running": true,
"Paused": false,
"Restarting": false,
"OOMKilled": false,
"Dead": false,
"Pid": 4597,
"ExitCode": 0,
"Error": "",
"StartedAt": "2016-09-16T08:09:34.53403504Z",
"FinishedAt": "2016-09-16T08:06:44.365106765Z"
}
Docker 命令概述¶
- 容器从生到死整个生命周期
root@devstack:/home/sammy# docker create --name web31 training/webapp python app.py #创建名字为 web31 的容器
7465f4cb7c49555af32929bd1bc4213f5e72643c0116450e495b71c7ec128502
root@devstack:/home/sammy# docker inspect --format='{{.State.Status}}' web31 #其状态为 created
created
root@devstack:/home/sammy# docker start web31 #启动容器
web31root@devstack:/home/sammy# docker exec -it web31 /bin/bash #在容器中运行 bash 命令
root@devstack:/home/sammy# docker inspect --format='{{.State.Status}}' web31 #其状态为 running
running
root@devstack:/home/sammy# docker pause web31 #暂停容器
web31
root@devstack:/home/sammy# docker inspect --format='{{.State.Status}}' web31
paused
root@devstack:/home/sammy# docker unpause web31 #继续容器
web31
root@devstack:/home/sammy# docker inspect --format='{{.State.Status}}' web31
running
root@devstack:/home/sammy# docker rename web31 newweb31 #重命名
root@devstack:/home/sammy# docker inspect --format='{{.State.Status}}' newweb31
running
root@devstack:/home/sammy# docker top newweb31 #在容器中运行 top 命令
UID PID PPID C STIME TTY TIME CMD
root 5009 4979 0 16:28 ? 00:00:00 python app.py
root@devstack:/home/sammy# docker logs newweb31 #获取容器的日志
* Running on http://0.0.0.0:5000/ (Press CTRL+C to quit)
root@devstack:/home/sammy# docker stop newweb31 #停止容器
newweb31
root@devstack:/home/sammy# docker inspect --format='{{.State.Status}}' newweb31
exited
root@devstack:/home/sammy# docker rm newweb31 #删除容器
newweb31
root@devstack:/home/sammy# docker inspect --format='{{.State.Status}}' newweb31
Error: No such image, container or task: newweb31
- docker stop 和 docker kill
在docker stop 命令执行的时候,会先向容器中PID为1的进程发送系统信号 SIGTERM,然后等待容器中的应用程序终止执行,如果等待时间达到设定的超时时间(默认为 10秒,用户可以指定特定超时时长),会继续发送SIGKILL的系统信号强行kill掉进程。在容器中的应用程序,可以选择忽略和不处理SIGTERM信号,不过一旦达到超时时间,程序就会被系统强行kill掉,因为SIGKILL信号是直接发往系统内核的,应用程序没有机会去处理它。
比如运行 docker stop web5 -t 20 命令。
-
使用 docker cp 在 host 和 container 之间拷贝文件或者目录
-
docker export 和 import
docker export:将一个容器的文件系统打包为一个压缩文件
root@devstack:/home/sammy# docker export web5 -o ./web5
root@devstack:/home/sammy# ls
chroot devstack Dockerfile mongodbdocker mydockerbuild web5 webapp
docker import:从一个压缩文件创建一个镜像
root@devstack:/home/sammy# docker import web5 web5img -m "imported on 0916"
sha256:745bb258be0a69a517367667646148bb2f662565bb3d222b50c0c22e5274a926
root@devstack:/home/sammy# docker history web5img
IMAGE CREATED CREATED BY SIZE COMMENT
745bb258be0a 6 seconds ago
资料¶
镜像¶
镜像(image)是动态的容器的静态表示(specification),包括容器所要运行的应用代码以及运行时的配置。Docker 镜像包括一个或者多个只读层( read-only layers ),因此,镜像一旦被创建就再也不能被修改了。一个运行着的Docker 容器是一个镜像的实例( instantiation )。每个运行着的容器都有一个可写层( writable layer ,也成为容器层 container layer),它位于底下的若干只读层之上。运行时的所有变化,包括对数据和文件的写和更新,都会保存在这个层中。因此,从同一个镜像运行的多个容器包含了不同的容器层。
Host OS VS Guest OS VS Base image¶
比如,一台主机安装的是 Centos 操作系统,现在在上面跑一个 Ubuntu 容器。此时,Host OS 是 Centos,Guest OS 是 Ubuntu。Guest OS 也被成为容器的 Base Image。
因为所有Linux发行版都包含同一个linux 内核(有轻微修改),以及不同的自己的软件,因此,会很容易地将某个 userland 软件安装在linux 内核上,来模拟不同的发行版环境。比如说,在 Ubuntu 上运行 Centos 容器,这意味着从 Centos 获取 userland 软件,运行在 Ubuntu 内核上。因此,这就像在同一个操作系统(linux 内核)上运行不同的 userland 软件(发行版的)。这就是为什么Docker 不支持在 Linux 主机上运行 FreeBSD 或者windows 容器。
Dockerfile 语法¶
ADD 和 COPY¶
Add:将 host 上的文件拷贝到或者将网络上的文件下载到容器中的指定目录。
两者都可以从本地拷贝文件,那两者有什么区别呢?
- ADD 多了2个功能, 下载URL和对支持的压缩格式的包进行解压. 其他都一样。比如 ADD http://foo.com/bar.go /tmp/main.go 会将文件从因特网上方下载下来,ADD /foo.tar.gz /tmp/ 会将压缩文件解压再COPY过去
- 如果你不希望压缩文件拷贝到container后会被解压的话, 那么使用COPY。
- 如果需要自动下载URL并拷贝到container的话, 请使用ADD
RUN¶
运行命令,结果会生成镜像中的一个新层
VOLUME¶
允许容器访问host上某个目录
CMD¶
CMD:在容器被创建后执行的命令,和 RUN 不同,它是在构造容器时候所执行的命令
CMD 有三种格式:
- CMD ["executable","param1","param2"] (like an exec, preferred form)
- CMD ["param1","param2"] (作为 ENTRYPOINT 的参数)
- CMD command param1 param2 (作为 shell 运行)
一个Dockerfile里只能有一个CMD,如果有多个,只有最后一个生效。
ENTRYPOINT¶
ENTRYPOINT :设置默认应用,会保证每次容器被创建后该应用都会被执行。
CMD 和 ENTRYPOINT区别与联系?
- Dockerfile 至少需要指定一个 CMD 或者 ENTRYPOINT 指令
- CMD 可以用来指定 ENTRYPOINT 指令的参数
- CMD 和 ENTRYPOINT 都存在时,CMD 的指令作为 ENTRYPOINT 的参数
| 没有 ENTRYPOINT | ENTRYPOINT exec_entry p1_entry | ENTRYPOINT [“exec_entry”, “p1_entry”] | |
|---|---|---|---|
| 没有 CMD | 错误,不允许 | /bin/sh -c exec_entry p1_entry | exec_entry p1_entry |
| CMD [“exec_cmd”, “p1_cmd”] | exec_cmd p1_cmd | /bin/sh -c exec_entry p1_entry exec_cmd p1_cmd | exec_entry p1_entry exec_cmd p1_cmd |
| CMD [“p1_cmd”, “p2_cmd”] | p1_cmd p2_cmd | /bin/sh -c exec_entry p1_entry p1_cmd p2_cmd | exec_entry p1_entry p1_cmd p2_cmd |
| CMD exec_cmd p1_cmd | /bin/sh -c exec_cmd p1_cmd | /bin/sh -c exec_entry p1_entry /bin/sh -c exec_cmd p1_cmd | exec_entry p1_entry /bin/sh -c exec_cmd p1_cmd |
| 备注 | 只有 CMD 时,执行 CMD 定义的指令 | CMD 和 ENTRYPOINT 都存在时,CMD 的指令作为 ENTRYPOINT 的参数 |
docker与lxc是什么关系,有什么区别?¶
lxc 是早期版本 docker 的一个基础组件,docker 主要用到了它对 Cgroup 和 Namespace 两个内核特性的控制。随着 docker 的发展,它自己封装了 libcontainer (golang 的库)来实现 Cgroup 和 Namespace 控制,从而消除了对 lxc 的依赖。
Docker, LXC都是容器化的实现,底层都依赖内核的namespaces 和Cgroup,在多个方面又有很多不同。
资料¶
Docker 容器使用 cgroups 限制资源使用¶
Linux control groups¶
Linux Cgroups(Control Groups) 可让您为系统中所运行任务(进程)的用户定义组群分配资源 — 比如 CPU 时间、系统内存、网络带宽或者这些资源的组合。您可以监控您配置的 cgroup,拒绝 cgroup 访问某些资源,甚至在运行的系统中动态配置您的 cgroup。所以,可以将 controll groups 理解为 controller (system resource) (for) (process)groups,也就是是说它以一组进程为目标进行系统资源分配和控制。 Linux Cgroups(Control Groups) 提供了**对一组进程及将来的子进程的资源的限制**,控制和统计的能力,这些资源包括**CPU,内存,存储,网络**等。通过Cgroups,可以方便的限制某个进程的资源占用,并且可以实时的监控进程的监控和统计信息。
它主要提供了如下功能:
- Resource limitation: 限制资源使用,比如内存使用上限以及文件系统的缓存限制。
- Prioritization: 优先级控制,比如:CPU利用和磁盘IO吞吐。
- Accounting: 一些审计或一些统计,主要目的是为了计费。
- Control: 挂起进程,恢复执行进程。
使用 cgroup,系统管理员可更具体地控制对系统资源的分配、优先顺序、拒绝、管理和监控。可更好地根据任务和用户分配硬件资源,提高总体效率。
在实践中,系统管理员一般会利用CGroup做下面这些事(有点像为某个虚拟机分配资源似的):
- 隔离一个进程集合(比如:nginx的所有进程),并限制他们所消费的资源,比如绑定CPU的核。
- 为这组进程分配其足够使用的内存
- 为这组进程分配相应的网络带宽和磁盘存储限制
- 限制访问某些设备(通过设置设备的白名单)
查看 linux 内核中是否启用了 cgroup:
vagrant@vagrant:~$ uname -r
4.4.0-101-generic
vagrant@vagrant:~$ cat /boot/config-4.4.0-101-generic | grep CGROUP
CONFIG_CGROUPS=y
# CONFIG_CGROUP_DEBUG is not set
CONFIG_CGROUP_FREEZER=y
CONFIG_CGROUP_PIDS=y
CONFIG_CGROUP_DEVICE=y
CONFIG_CGROUP_CPUACCT=y
CONFIG_CGROUP_HUGETLB=y
CONFIG_CGROUP_PERF=y
CONFIG_CGROUP_SCHED=y
CONFIG_BLK_CGROUP=y
# CONFIG_DEBUG_BLK_CGROUP is not set
CONFIG_CGROUP_WRITEBACK=y
CONFIG_NETFILTER_XT_MATCH_CGROUP=m
CONFIG_NET_CLS_CGROUP=m
CONFIG_CGROUP_NET_PRIO=y
CONFIG_CGROUP_NET_CLASSID=y
对应的 cgroup 的配置值如果是 'y',则表示已经被启用了。
Linux 系统中,一切皆文件。Linux 也将 cgroups 实现成了文件系统,方便用户使用:
vagrant@vagrant:~$ mount -t cgroup
cgroup on /sys/fs/cgroup/systemd type cgroup (rw,nosuid,nodev,noexec,relatime,xattr,release_agent=/lib/systemd/systemd-cgroups-agent,name=systemd)
cgroup on /sys/fs/cgroup/devices type cgroup (rw,nosuid,nodev,noexec,relatime,devices)
cgroup on /sys/fs/cgroup/perf_event type cgroup (rw,nosuid,nodev,noexec,relatime,perf_event)
cgroup on /sys/fs/cgroup/net_cls,net_prio type cgroup (rw,nosuid,nodev,noexec,relatime,net_cls,net_prio)
cgroup on /sys/fs/cgroup/blkio type cgroup (rw,nosuid,nodev,noexec,relatime,blkio)
cgroup on /sys/fs/cgroup/hugetlb type cgroup (rw,nosuid,nodev,noexec,relatime,hugetlb)
cgroup on /sys/fs/cgroup/cpuset type cgroup (rw,nosuid,nodev,noexec,relatime,cpuset)
cgroup on /sys/fs/cgroup/pids type cgroup (rw,nosuid,nodev,noexec,relatime,pids)
cgroup on /sys/fs/cgroup/cpu,cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,cpu,cpuacct)
cgroup on /sys/fs/cgroup/memory type cgroup (rw,nosuid,nodev,noexec,relatime,memory)
cgroup on /sys/fs/cgroup/freezer type cgroup (rw,nosuid,nodev,noexec,relatime,freezer)
vagrant@vagrant:~$ lssubsys -m
cpuset /sys/fs/cgroup/cpuset
cpu,cpuacct /sys/fs/cgroup/cpu,cpuacct
blkio /sys/fs/cgroup/blkio
memory /sys/fs/cgroup/memory
devices /sys/fs/cgroup/devices
freezer /sys/fs/cgroup/freezer
net_cls,net_prio /sys/fs/cgroup/net_cls,net_prio
perf_event /sys/fs/cgroup/perf_event
hugetlb /sys/fs/cgroup/hugetlb
pids /sys/fs/cgroup/pids
vagrant@vagrant:~$ ls -l /sys/fs/cgroup/
total 0
dr-xr-xr-x 6 root root 0 Jun 30 09:35 blkio
lrwxrwxrwx 1 root root 11 Jun 22 23:10 cpu -> cpu,cpuacct
lrwxrwxrwx 1 root root 11 Jun 22 23:10 cpuacct -> cpu,cpuacct
dr-xr-xr-x 6 root root 0 Jun 30 09:35 cpu,cpuacct
dr-xr-xr-x 3 root root 0 Jun 30 09:35 cpuset
dr-xr-xr-x 6 root root 0 Jun 30 09:35 devices
dr-xr-xr-x 3 root root 0 Jun 30 09:35 freezer
dr-xr-xr-x 3 root root 0 Jun 30 09:35 hugetlb
dr-xr-xr-x 6 root root 0 Jun 30 09:35 memory
lrwxrwxrwx 1 root root 16 Jun 22 23:10 net_cls -> net_cls,net_prio
dr-xr-xr-x 3 root root 0 Jun 30 09:35 net_cls,net_prio
lrwxrwxrwx 1 root root 16 Jun 22 23:10 net_prio -> net_cls,net_prio
dr-xr-xr-x 3 root root 0 Jun 30 09:35 perf_event
dr-xr-xr-x 6 root root 0 Jun 30 09:35 pids
dr-xr-xr-x 6 root root 0 Jun 30 09:35 systemd
Cgroups中的三个组件¶
cgroup¶
cgroup 是对进程分组管理的一种机制,一个cgroup包含一组进程,并可以在这个cgroup上增加Linux subsystem的各种参数的配置,将一组进程和一组subsystem的系统参数关联起来。
subsystem¶
subsystem 是一组资源控制的模块,一般包含有:
- blkio 设置对块设备(比如硬盘)的输入输出的访问控制
- cpu 设置cgroup中的进程的CPU被调度的策略
- cpuacct 可以统计cgroup中的进程的CPU占用
- cpuset 在多核机器上设置cgroup中的进程可以使用的CPU和内存(此处内存仅使用于NUMA架构)
- devices 控制cgroup中进程对设备的访问
- freezer 用于挂起(suspends)和恢复(resumes) cgroup中的进程
- memory 用于控制cgroup中进程的内存占用
- net_cls 用于将cgroup中进程产生的网络包分类(classify),以便Linux的tc(traffic controller) 可以根据分类(classid)区分出来自某个cgroup的包并做限流或监控。
- net_prio 设置cgroup中进程产生的网络流量的优先级
- ns 这个subsystem比较特殊,它的作用是cgroup中进程在新的namespace fork新进程(NEWNS)时,创建出一个新的cgroup,这个cgroup包含新的namespace中进程。
net_cls 和 tc 一起使用可用于限制进程发出的网络包所使用的网络带宽。当使用 cgroups network controll net_cls 后,指定进程发出的所有网络包都会被加一个 tag,然后就可以使用其他工具比如 iptables 或者 traffic controller (TC)来根据网络包上的 tag 进行流量控制。关于 TC 的文档,网上很多,这里不再赘述。
每个subsystem会关联到定义了相应限制的cgroup上,并对这个cgroup中的进程做相应的限制和控制,这些subsystem是逐步合并到内核中的,如何看到当前的内核支持哪些subsystem呢?可以安装cgroup的命令行工具(apt-get install cgroup-bin),然后通过lssubsys看到kernel支持的subsystem。
hierarchy¶
hierarchy 的功能是把一组cgroup串成一个树状的结构,一个这样的树便是一个hierarchy,通过这种树状的结构,Cgroups可以做到继承。比如我的系统对一组定时的任务进程通过cgroup1限制了CPU的使用率,然后其中有一个定时dump日志的进程还需要限制磁盘IO,为了避免限制了影响到其他进程,就可以创建cgroup2继承于cgroup1并限制磁盘的IO,这样cgroup2便继承了cgroup1中的CPU的限制,并且又增加了磁盘IO的限制而不影响到cgroup1中的其他进程。
三个组件相互的关系¶
Cgroups的是靠这三个组件的相互协作实现的,那么这三个组件是什么关系呢?
- 系统在创建新的hierarchy之后,系统中所有的进程都会加入到这个hierarchy的根cgroup节点中,这个cgroup根节点是hierarchy默认创建,后面在这个hierarchy中创建cgroup都是这个根cgroup节点的子节点。
- 一个subsystem只能附加到一个hierarchy上面
- 一个hierarchy可以附加多个subsystem
- 一个进程可以作为多个cgroup的成员,但是这些cgroup必须是在不同的hierarchy中
- 一个进程fork出子进程的时候,子进程是和父进程在同一个cgroup中的,也可以根据需要将其移动到其他的cgroup中。
Cgroups中的hierarchy是一种树状的组织结构,Kernel为了让对Cgroups的配置更直观,Cgroups通过一个虚拟的树状文件系统去做配置的,通过层级的目录虚拟出cgroup树。
术语¶
- 任务(Tasks):就是系统的一个进程。
- 控制组(Control Group):一组按照某种标准划分的进程,比如官方文档中的Professor和Student,或是WWW和System之类的,其表示了某进程组。Cgroups中的资源控制都是以控制组为单位实现。一个进程可以加入到某个控制组。而资源的限制是定义在这个组上,就像上面示例中我用的 hello 一样。简单点说,cgroup的呈现就是一个目录带一系列的可配置文件。
- 层级(Hierarchy):控制组可以组织成hierarchical的形式,既一颗控制组的树(目录结构)。控制组树上的子节点继承父结点的属性。简单点说,hierarchy就是在一个或多个子系统上的cgroups目录树。
- 子系统(Subsystem):一个子系统就是一个资源控制器,比如CPU子系统就是控制CPU时间分配的一个控制器。子系统必须附加到一个层级上才能起作用,一个子系统附加到某个层级以后,这个层级上的所有控制族群都受到这个子系统的控制。Cgroup的子系统可以有很多,也在不断增加中。
实验¶
通过 cgroups 限制进程的 CPU¶
运行之后,发现cpu占用几乎100%:
top - 16:06:57 up 7 days, 16:53, 2 users, load average: 0.82, 0.27, 0.10
Tasks: 1 total, 1 running, 0 sleeping, 0 stopped, 0 zombie
%Cpu(s):100.0 us, 0.0 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
KiB Mem : 4046588 total, 594524 free, 537964 used, 2914100 buff/cache
KiB Swap: 1048572 total, 1048480 free, 92 used. 3070952 avail Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
31208 vagrant 20 0 4220 724 656 R 99.3 0.0 1:06.14 a.out
接下来配置cgroup:
mkdir /sys/fs/cgroup/cpu/hello
cd /sys/fs/cgroup/cpu/hello
cat cpu.cfs_quota_us // 默认创建hello目录之后,自动创建cfs相关文件
echo 20000 > cpu.cfs_quota_us // 若非root用户,需sudo sh -c "echo 20000 > cpu.cfs_quota_us"
echo 31208 > tasks // 31208为上面c程序进程id
然后再来看看这个进程的 CPU 占用情况:
Tasks: 152 total, 2 running, 150 sleeping, 0 stopped, 0 zombie
%Cpu(s): 17.1 us, 0.0 sy, 0.0 ni, 82.9 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
KiB Mem : 4046588 total, 592952 free, 539276 used, 2914360 buff/cache
KiB Swap: 1048572 total, 1048480 free, 92 used. 3069628 avail Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
31208 vagrant 20 0 4220 724 656 R 19.9 0.0 6:02.71 a.out
它占用的 CPU 几乎就是 20%,也就是我们预设的阈值。这说明我们通过上面的步骤,成功地将这个进程运行所占用的 CPU 资源限制在某个阈值之内了。
如果此时再启动另一个进程并将其 id 加入 tasks 文件(sudo sh -c "echo 31618 >> tasks),则**两个进程会共享设定的 CPU 限制**,即每个进程各占10%的cpu资源:
top - 16:17:51 up 7 days, 17:04, 4 users, load average: 1.39, 1.24, 0.71
Tasks: 158 total, 3 running, 155 sleeping, 0 stopped, 0 zombie
%Cpu(s): 18.6 us, 0.3 sy, 0.0 ni, 81.1 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
KiB Mem : 4046588 total, 578088 free, 550312 used, 2918188 buff/cache
KiB Swap: 1048572 total, 1048480 free, 92 used. 3058200 avail Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
31618 vagrant 20 0 4220 648 580 R 10.0 0.0 2:43.16 a.out
31208 vagrant 20 0 4220 724 656 R 9.6 0.0 7:04.75 a.out
通过 cgroups 限制进程的 Memory¶
vagrant@vagrant:~$ cd /sys/fs/cgroup/memory
vagrant@vagrant:/sys/fs/cgroup/memory$ sudo mkdir hello
vagrant@vagrant:/sys/fs/cgroup/memory$ cd hello/
vagrant@vagrant:/sys/fs/cgroup/memory/hello$ cat memory.limit_in_bytes
9223372036854771712
vagrant@vagrant:/sys/fs/cgroup/memory/hello$ sudo sh -c "echo 64k > memory.limit_in_bytes"
vagrant@vagrant:/sys/fs/cgroup/memory/hello$ cat memory.limit_in_bytes
65536
vagrant@vagrant:/sys/fs/cgroup/memory/hello$ sudo sh -c "echo 31208 > tasks" // 将进程31208加入到task文件中
限制进程的 I/O¶
查看io速度:
vagrant@vagrant:~$ sudo dd if=/dev/sda1 of=/dev/null
997376+0 records in
997376+0 records out
510656512 bytes (511 MB, 487 MiB) copied, 0.497896 s, 1.0 GB/s
接着做下面的操作:
mkdir /sys/fs/cgroup/blkio/io
cd /sys/fs/cgroup/blkio/io
ls -l /dev/sda1
brw-rw---- 1 root disk 8, 1 Jun 22 23:10 /dev/sda1
echo '8:0 1048576' > /sys/fs/cgroup/blkio/io/blkio.throttle.read_bps_device
echo 2725 > /sys/fs/cgroup/blkio/io/tasks
Docker 对 cgroups 的使用¶
默认情况下,Docker 启动一个容器后,会在 /sys/fs/cgroup 目录下的各个资源目录下生成以容器 ID 为名字的目录(group),比如:
/sys/fs/cgroup/cpu/docker/da577b6b5bc89ae28080778bf8e3d7560b32d1efaf499cff7f414ca2ca7d4ca5
此时 cpu.cfs_quota_us 的内容为 -1,表示默认情况下并没有限制容器的 CPU 使用。在容器被 stopped 后,该目录被删除。
限制容器可用的 CPU¶
限制可用的 CPU 个数¶
docker 1.13 及更高的版本上,能够很容易的限制容器可以使用的主机 CPU 个数。只需要通过 --cpus 选项指定容器可以使用的 CPU 个数就可以了,并且还可以指定如 1.5 之类的小数。
创建测试镜像(docker build -t mystress:latest .):
指定使用2个CPU:
通过docker stats命令可以查看到大概占用2个cpu:
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
6f2d12f0183e inspiring_spence 200.89% 2.199MiB / 7.771GiB 0.03% 1.03kB / 138B 0B / 0B 6
需要注意的是对于进程来说是没有 CPU 个数这一概念的,内核只能通过进程消耗的 CPU 时间片来统计出进程占用 CPU 的百分比。上面CPU%为200.11%,说明该进程占用2个CPU。对于4核心的系统,但这不意味着有2个cpu使用100%,另外两个使用0%。实际上是每个CPU都会使用,即每个核心使用了50%:
top - 17:55:34 up 7 min, 2 users, load average: 0.21, 0.20, 0.11
Tasks: 179 total, 5 running, 174 sleeping, 0 stopped, 0 zombie
%Cpu0 : 50.7 us, 0.0 sy, 0.0 ni, 49.3 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu1 : 50.5 us, 0.0 sy, 0.0 ni, 49.5 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu2 : 50.5 us, 0.0 sy, 0.0 ni, 49.5 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu3 : 48.3 us, 0.7 sy, 0.0 ni, 51.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
更早的版本完成同样的功能我们需要配合使用两个选项:--cpu-period 和 --cpu-quota(1.13 及之后的版本仍然支持这两个选项)。下面的命令实现相同的结果:
cpu-period, cpu-quota它们的单位是微秒,100000 表示 100 毫秒,200000 表示 200 毫秒。它们在这里的含义是:在每 100 毫秒的时间里,运行进程使用的 CPU 时间最多为 200 毫秒(需要两个 CPU 各执行 100 毫秒,需要两个 CPU 各执行 100 毫秒)。这两个参数含义参考CFS BandWith Control指定固定的 CPU¶
通过 --cpus 选项我们无法让容器始终在一个或某几个 CPU 上运行,但是通过 --cpuset-cpus 选项却可以做到!这是非常有意义的,因为现在的多核系统中每个核心都有自己的缓存,如果频繁的调度进程在不同的核心上执行势必会带来缓存失效等开销。下面我们就演示如何设置容器使用固定的 CPU,下面的命令为容器设置了 --cpuset-cpus 选项,指定运行容器的 CPU 编号为 1
查看CPU负载情况:
top - 17:56:58 up 9 min, 2 users, load average: 1.30, 0.60, 0.26
Tasks: 182 total, 5 running, 177 sleeping, 0 stopped, 0 zombie
%Cpu0 : 0.3 us, 0.0 sy, 0.0 ni, 98.0 id, 0.0 wa, 0.0 hi, 1.6 si, 0.0 st
%Cpu1 :100.0 us, 0.0 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu2 : 0.0 us, 0.0 sy, 0.0 ni,100.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu3 : 0.0 us, 0.0 sy, 0.0 ni,100.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
这次只有 Cpu1 达到了 100%,其它的 CPU 并未被容器使用。我们还可以反复的执行 stress -c 4 命令,但是始终都是 Cpu1 在干活。再看看容器的 CPU 负载,也是只有 100%:
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
20431b28c268 trusting_haslett 99.64% 1.746MiB / 7.771GiB 0.02% 1.02kB / 0B 0B / 0B 6
--cpuset-cpus 选项还可以一次指定多个 CPU:
观察CPU负载:
top - 18:02:19 up 14 min, 2 users, load average: 1.72, 1.30, 0.72
Tasks: 177 total, 5 running, 172 sleeping, 0 stopped, 0 zombie
%Cpu0 : 0.3 us, 0.0 sy, 0.0 ni, 99.3 id, 0.0 wa, 0.0 hi, 0.3 si, 0.0 st
%Cpu1 :100.0 us, 0.0 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu2 : 0.3 us, 0.0 sy, 0.0 ni, 99.7 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu3 :100.0 us, 0.0 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
MiB Mem : 7957.8 total, 6286.8 free, 303.3 used, 1367.7 buff/cache
MiB Swap: 0.0 total, 0.0 free, 0.0 used. 7397.6 avail Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
5990 root 20 0 3864 100 0 R 52.8 0.0 0:13.81 stress
5992 root 20 0 3864 100 0 R 51.2 0.0 0:13.68 stress
5989 root 20 0 3864 100 0 R 47.8 0.0 0:13.98 stress
5991 root 20 0 3864 100 0 R 47.5 0.0 0:13.57 stress
Cpu1 和 Cpu3 的负载都达到了 100%。容器的 CPU 负载也达到了 200%:
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
5d1c1df38895 epic_einstein 200.29% 2.188MiB / 7.771GiB 0.03% 1.09kB / 0B 0B / 0B 6
设置使用 CPU 的权重¶
当 CPU 资源充足时,设置 CPU 的权重是没有意义的。只有在容器争用 CPU 资源的情况下, CPU 的权重才能让不同的容器分到不同的 CPU 用量。--cpu-shares 选项用来设置 CPU 权重,它的默认值为 1024。我们可以把它设置为 2 表示很低的权重,但是设置为 0 表示使用默认值 1024。
分别运行两个容器,指定它们都使用 Cpu0,并分别设置 --cpu-shares 为 512 和 1024:
docker run -it --rm --cpuset-cpus="0" --cpu-shares=512 mystress:latest /bin/bash
docker run -it --rm --cpuset-cpus="0" --cpu-shares=1024 mystress:latest /bin/bash
此时主机 Cpu0 的负载为 100%:
top - 18:07:51 up 20 min, 3 users, load average: 7.01, 4.08, 2.04
Tasks: 189 total, 9 running, 180 sleeping, 0 stopped, 0 zombie
%Cpu0 :100.0 us, 0.0 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu1 : 0.0 us, 0.0 sy, 0.0 ni, 98.4 id, 0.0 wa, 0.0 hi, 1.6 si, 0.0 st
%Cpu2 : 0.3 us, 0.0 sy, 0.0 ni, 99.7 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu3 : 0.0 us, 0.0 sy, 0.0 ni,100.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
MiB Mem : 7957.8 total, 6247.2 free, 341.5 used, 1369.1 buff/cache
MiB Swap: 0.0 total, 0.0 free, 0.0 used. 7363.8 avail Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
6450 root 20 0 3864 100 0 R 15.9 0.0 0:05.70 stress
6451 root 20 0 3864 100 0 R 15.9 0.0 0:05.70 stress
6452 root 20 0 3864 100 0 R 15.9 0.0 0:05.70 stress
6453 root 20 0 3864 100 0 R 15.9 0.0 0:05.70 stress
6302 root 20 0 3864 104 0 R 9.3 0.0 0:20.40 stress
6304 root 20 0 3864 104 0 R 9.3 0.0 0:20.40 stress
6301 root 20 0 3864 104 0 R 9.0 0.0 0:20.39 stress
6303 root 20 0 3864 104 0 R 9.0 0.0 0:20.39 stress
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
31d1800d6a7d brave_shannon 36.16% 1.699MiB / 7.771GiB 0.02% 1.02kB / 0B 0B / 0B 6
c325fadb8d2c nervous_edison 62.92% 1.816MiB / 7.771GiB 0.02% 586B / 0B 0B / 0B 6
两个容器分享一个 CPU,所以总量应该是 100%。具体每个容器分得的负载则取决于 --cpu-shares 选项的设置!我们的设置分别是 512 和 1024,则它们分得的比例为 1:2。在本例中如果想让两个容器各占 50%,只要把 --cpu-shares 选项设为相同的值就可以了。
需要注意: cgroup 只能限制 CPU 的使用,而不能保证CPU的使用。也就是说, 使用 cpuset-cpus,可以让容器在指定的CPU或者核上运行,但是不能确保它独占这些CPU;cpu-shares 是个相对值,只有在CPU不够用的时候才其作用。也就是说,当CPU够用的时候,每个容器会分到足够的CPU;不够用的时候,会按照指定的比重在多个容器之间分配CPU
限制容器可用的内存¶
为什么要限制容器对内存的使用?¶
限制容器不能过多的使用主机的内存是非常重要的。对于 linux 主机来说,一旦内核检测到没有足够的内存可以分配,就会扔出 OOME(Out Of Memmory Exception),并开始杀死一些进程用于释放内存空间。糟糕的是任何进程都可能成为内核猎杀的对象,包括 docker daemon 和其它一些重要的程序。更危险的是如果某个支持系统运行的重要进程被干掉了,整个系统也就宕掉了!这里我们考虑一个比较常见的场景,大量的容器把主机的内存消耗殆尽,OOME 被触发后系统内核立即开始杀进程释放内存。如果内核杀死的第一个进程就是 docker daemon 会怎么样?结果是没有办法管理运行中的容器了,这是不能接受的! 针对这个问题,docker 尝试通过调整 docker daemon 的 OOM 优先级来进行缓解。内核在选择要杀死的进程时会对所有的进程打分,直接杀死得分最高的进程,接着是下一个。当 docker daemon 的 OOM 优先级被降低后(注意容器进程的 OOM 优先级并没有被调整),docker daemon 进程的得分不仅会低于容器进程的得分,还会低于其它一些进程的得分。这样 docker daemon 进程就安全多了。 我们可以通过下面的脚本直观的看一下当前系统中所有进程的得分情况:
#!/bin/bash
for proc in $(find /proc -maxdepth 1 -regex '/proc/[0-9]+'); do
printf "%2d %5d %s\n" \
"$(cat $proc/oom_score)" \
"$(basename $proc)" \
"$(cat $proc/cmdline | tr '\0' ' ' | head -c 50)"
done 2>/dev/null | sort -nr | head -n 40
有了上面的机制后是否就可以高枕无忧了呢!不是的,docker 的官方文档中一直强调这只是一种缓解的方案,并且为我们提供了一些降低风险的建议:
- 通过测试掌握应用对内存的需求
- 保证运行容器的主机有充足的内存
- 限制容器可以使用的内存
- 为主机配置 swap
限制内存使用上限¶
-m(--memory=) 选项可以完成限制内存使用上限的配置:
stress 命令会创建一个进程并通过 malloc 函数分配内存:
通过 docker stats 命令查看实际情况:
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
5a2eff8a21d0 test1 0.00% 1.758MiB / 300MiB 0.59% 1.02kB / 0B 0B / 0B 1
上面的 docker run 命令中通过 -m 选项限制容器使用的内存上限为 300M。同时设置 memory-swap 值为 -1,它表示容器程序使用内存的受限,而可以使用的 swap 空间使用不受限制(宿主机有多少 swap 容器就可以使用多少)。 下面我们通过 top 命令来查看 stress 进程内存的实际情况:
上面的截图中先通过 pgrep 命令查询 stress 命令相关的进程,进程号比较大的那个是用来消耗内存的进程,我们就查看它的内存信息。VIRT 是进程虚拟内存的大小,所以它应该是 500M。RES 为实际分配的物理内存数量,我们看到这个值就在 300M 上下浮动。看样子我们已经成功的限制了容器能够使用的物理内存数量。
限制可用的 swap 大小¶
强调一下 --memory-swap 是必须要与 --memory 一起使用的。正常情况下, --memory-swap 的值包含容器可用内存和可用 swap。所以 --memory="300m" --memory-swap="1g" 的含义为:
容器可以使用 300M 的物理内存,并且可以使用 700M(1G -300M) 的 swap。--memory-swap 居然是容器可以使用的物理内存和可以使用的 swap 之和!把 --memory-swap 设置为 0 和不设置是一样的,此时如果设置了 --memory,容器可以使用的 swap 大小为 --memory 值的两倍。
go语言实现通过cgroup限制容器的资源¶
package main
import (
"os/exec"
"path"
"os"
"fmt"
"io/ioutil"
"syscall"
"strconv"
)
const cgroupMemoryHierarchyMount = "/sys/fs/cgroup/memory"
func main() {
if os.Args[0] == "/proc/self/exe" {
//容器进程
fmt.Printf("current pid %d", syscall.Getpid())
fmt.Println()
cmd := exec.Command("sh", "-c", `stress --vm-bytes 200m --vm-keep -m 1`)
cmd.SysProcAttr = &syscall.SysProcAttr{
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
cmd := exec.Command("/proc/self/exe")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
fmt.Println("ERROR", err)
os.Exit(1)
} else {
//得到fork出来进程映射在外部命名空间的pid
fmt.Printf("%v", cmd.Process.Pid)
// 在系统默认创建挂载了memory subsystem的Hierarchy上创建cgroup
os.Mkdir(path.Join(cgroupMemoryHierarchyMount, "testmemorylimit"), 0755)
// 将容器进程加入到这个cgroup中
ioutil.WriteFile(path.Join(cgroupMemoryHierarchyMount, "testmemorylimit", "tasks") , []byte(strconv.Itoa(cmd.Process.Pid)), 0644)
// 限制cgroup进程使用
ioutil.WriteFile(path.Join(cgroupMemoryHierarchyMount, "testmemorylimit", "memory.limit_in_bytes") , []byte("100m"), 0644)
}
cmd.Process.Wait()
}
资料¶
Docker 使用 Linux namespace 隔离容器的运行环境¶
Linux namespace¶
Linux 内核从版本 2.4.19 开始陆续引入了 namespace 的概念。其目的是将某个特定的全局系统资源(global system resource)通过抽象方法使得namespace 中的进程看起来拥有它们自己的隔离的全局系统资源实例(The purpose of each namespace is to wrap a particular global system resource in an abstraction that makes it appear to the processes within the namespace that they have their own isolated instance of the global resource. )。Linux 内核中实现了六种 namespace,按照引入的先后顺序,列表如下:
| namespace | 引入的相关内核版本 | 被隔离的全局系统资源 | 在容器语境下的隔离效果 |
|---|---|---|---|
| Mount namespaces | Linux 2.4.19 | 文件系统挂接点 | 每个容器能看到不同的文件系统层次结构 |
| UTS namespaces | Linux 2.6.19 | nodename 和 domainname | 每个容器可以有自己的 hostname 和 domainame |
| IPC namespaces | Linux 2.6.19 | 特定的进程间通信资源,包括System V IPC 和 POSIX message queues | 每个容器有其自己的 System V IPC 和 POSIX 消息队列文件系统,因此,只有在同一个 IPC namespace 的进程之间才能互相通信 |
| PID namespaces | Linux 2.6.24 | 进程 ID 数字空间 (process ID number space) | 每个 PID namespace 中的进程可以有其独立的 PID; 每个容器可以有其 PID 为 1 的root 进程;也使得容器可以在不同的 host 之间迁移,因为 namespace 中的进程 ID 和 host 无关了。这也使得容器中的每个进程有两个PID:容器中的 PID 和 host 上的 PID。 |
| Network namespaces | 始于Linux 2.6.24 完成于 Linux 2.6.29 | 网络相关的系统资源 | 每个容器用有其独立的网络设备,IP 地址,IP 路由表,/proc/net 目录,端口号等等。这也使得一个 host 上多个容器内的同一个应用都绑定到各自容器的 80 端口上。 |
| User namespaces | 始于 Linux 2.6.23 完成于 Linux 3.8) | 用户和组 ID 空间 | 在 user namespace 中的进程的用户和组 ID 可以和在 host 上不同; 每个 container 可以有不同的 user 和 group id;一个 host 上的非特权用户可以成为 user namespace 中的特权用户; |
Linux namespace 的概念说简单也简单说复杂也复杂。简单来说,我们只要知道,处于某个 namespace 中的进程,能看到独立的它自己的隔离的某些特定系统资源;复杂来说,可以去看看 Linux 内核中实现 namespace 的原理。
Docker 容器使用 linux namespace 做运行环境隔离¶
当 Docker 创建一个容器时,它会创建新的以上六种 namespace 的实例,然后把容器中的所有进程放到这些 namespace 之中,使得Docker 容器中的进程只能看到隔离的系统资源。
PID namespace¶
我们能看到同一个进程,在容器内外(容器内核host上)的 PID 是不同的:
在容器内 PID 是 1,PPID 是 0。 在容器外 PID 是 2198, PPID 是 2179 即 docker-containerd-shim 进程.
关于 containerd,containerd-shim 和 container 的关系,文章 中的下图可以说明:
- Docker 引擎管理着镜像,然后移交给 containerd 运行,containerd 再使用 runC 运行容器。
- Containerd 是一个简单的守护进程,它可以使用 runC 管理容器,使用 gRPC 暴露容器的其他功能。它管理容器的开始,停止,暂停和销毁。由于容器运行时是孤立的引擎,引擎最终能够启动和升级而无需重新启动容器。
- runC是一个轻量级的工具,它是用来运行容器的,只用来做这一件事,并且这一件事要做好。runC基本上是一个小命令行工具且它可以不用通过Docker引擎,直接就可以使用容器。
因此,容器中的主应用在 host 上的父进程是 containerd-shim,是它通过工具 runC 来启动这些进程的。这也能看出来,pid namespace 通过将 host 上 PID 映射为容器内的 PID, 使得容器内的进程看起来有个独立的 PID 空间。
UTS namespace¶
容器可以有自己的 hostname 和 domainname
user namespace¶
Linux 内核中的 user namespace¶
老版本中,Linux 内核里面只有一个数据结构负责处理用户和组。内核从3.8 版本开始实现了 user namespace。通过在 clone() 系统调用中使用 CLONE_NEWUSER 标志,一个单独的 user namespace 就会被创建出来。在新的 user namespace 中,有一个虚拟的用户和用户组的集合。这些用户和用户组,从 uid/gid 0 开始,被映射到该 namespace 之外的 非 root 用户。
在现在的linux内核中,管理员可以创建成千上万的用户。这些用户可以被映射到每个 user namespace 中。通过使用 user namespace 功能,不同的容器可以有完全不同的 uid 和 gid 数字。容器 A 中的 User 500 可能被映射到容器外的 User 1500,而容器 B 中的 user 500 可能被映射到容器外的用户 2500.
为什么需要这么做呢?因为在容器中,提供 root 访问权限有其特殊用途。想象一下,容器 A 中的 root 用户 (uid 0) 被映射到宿主机上的 uid 1000,容器B 中的 root 被映射到 uid 2000.类似网络端口映射,这允许管理员在容器中创建 root 用户,而不需要在宿主机上创建。
Docker 对 user namespace 的支持¶
User namespace是从docker1.10开始被支持,并且不是默认开启的,即容器内的进程的运行用户就是 host 上的 root 用户,这样的话,当 host 上的文件或者目录作为 volume 被映射到容器以后,容器内的进程其实是有 root 的几乎所有权限去修改这些 host 上的目录的,这会有很大的安全问题。如何开启参见后面资料连接。
检查 linux 操作系统是否启用了 user namespace¶
vagrant@vagrant:~$ uname -a
Linux vagrant 4.4.0-101-generic #124-Ubuntu SMP Fri Nov 10 18:29:59 UTC 2017 x86_64 x86_64 x86_64 GNU/Linux
vagrant@vagrant:~$ cat /boot/config-4.4.0-101-generic | grep CONFIG_USER_NS
CONFIG_USER_NS=y
如果是 「y」,则启用了,否则未启用。同样地,可以查看其它 namespace:
资料¶
Ended: 容器
Linux常用命令大全¶
1. 文件与目录操作¶
touch - 创建文件¶
若文件不存在则创建文件,若存在则修改文件的时间(存取时间和更改时间)
mv - 移动/重命名文件和目录¶
mkdir - 创建目录¶
cp - 复制文件和目录¶
cp a.txt b.txt // 复制a.txt到b.txt,如果b.txt不存在,则创建,存在则覆盖
cp -i a.txt b.txt // 如果b.txt存在,覆盖b.txt之前,会询问用户
cp a.txt b.txt dir1/ // 复制a.txt, b.txt 到dir1目录
cp *.txt dir1/ // 复制txt文件到dir1目录
cp -r dir1/*.txt dir2/ // 递归地复制dir1目录下的txt文件到dir2目录下
cp -u a.txt b.txt // 当b.txt不存在,或者a.txt新于b.txt时候才会复制
rm - 删除文件和目录¶
rm file1 // 删除file1
rm -i file1 // 删除文件file1,询问用户确认
rm -r dir1/ // 递归删除dir1以及目录下所有文件
rm -rf dir1/ dir2/ // 递归删除dir1,dir2目录,即使目录dir2不存在也不会终止
ln - 创建链接¶
stat - 显示文件的详细信息¶
stat查看文件时候,会显示三种类型时间:分别是Access time,Modify time,Change time。
- access time:表示我们最后一次访问(仅仅是访问,没有改动)文件的时间。比如more,less,cat,tail等命令都会更改atime
- modify time:表示我们最后一次修改文件的时间。touch会更改这三种类型时间
- change time:表示我们最后一次对文件属性改变的时间,包括权限,大小,属性等等。chmod,chown会更改此时间。
stat a.txt // 显示文件的mtime,atime,ctime
ls -l a.txt // 列出文件的mtime
ls -lc a.txt // 列出文件的ctime
ls -lu a.txt // 列出文件的atime
2. I/O重定向¶
标准输入,输出和错误,在shell内部它们为文件描述符0,1和2
ls -l dir1/ >> ls-output.txt // 重定向ls命令输出的内容到ls-output.txt文件,>等同于1>
ls -l /usr/bintmp 2>ls-error.txt // 重定向标准错误输出到ls-error.txt
ls -l /bin/usr > ls-output.txt 2>&1 // 重定向标准输出和错误到同一个文件
ls -l /usr /usr/bintmp 2>ls-error.txt 1>ls-output.txt // 错误重定向到ls-error.txt 输出重定向到ls-output.txt
ls /usr/bin | tee -a ls.txt | grep zip //tee 从stdin读取数据,并同时输出到stdout和文件。tee命令相当于管道的一个T型接头, -a表示追加
nginx -c nginx.conf > /dev/null 2>&1// 屏蔽标准和错误输出
3. 包管理¶
不同的 Linux 发行版使用不同的打包系统。一般而言,大多数发行版分别属于两大包管理技术阵营: Debian 的”.deb”,和红帽的”.rpm”。也有一些重要的例外,比方说 Gentoo, Slackware,和 Foresight,但大多数会使用这两个基本系统中的一个。
软件包管理系统通常由两种工具类型组成:底层工具用来处理这些任务,比方说安装和删除软件包文件, 和**上层工具完成元数据搜索和依赖解析**。
表3.1主要的包管理系统家族
| 包管理系统 | 发行版 (部分列表) | 上层工具 | 底层工具 |
|---|---|---|---|
| Debian Style (.deb) | Debian, Ubuntu, Xandros, Linspire | apt-get, aptitude | dpkg |
| Red Hat Style (.rpm) | Fedora, CentOS, Red Hat Enterprise Linux, OpenSUSE, Mandriva, PCLinuxOS | yum | rpm |
一个rpm包的名称分为包全名和包名,包全名如httpd-2.2.15-39.el6.centos.x86_64.rpm,包全名中各部分的意义如下:
| 符号 | 说明 |
|---|---|
| httpd | 包名 |
| 2.2.15 | 版本号,版本号格式[ 主版本号.[ 次版本号.[ 修正号 ] ] ] |
| 39 | 软件发布次数 |
| el6.centos | 适合的操作系统平台以及适合的操作系统版本 |
| x86_64 | 适合的硬件平台,硬件平台根据cpu来决定,有i386、i586、i686、x86_64、noarch或者省略,noarch或省略表示不区分硬件平台 |
| rpm | 软件包后缀扩展名 |
// 检查是否安装过某软件包
rpm -qa | grep "软件或者包的名字"
dpkg -l | grep "软件或者包的名字"
yum list installed | grep "软件名或者包名"
4. 权限与进程¶
id – 显示用户身份号¶
chmod - 改变文件模式¶
表4.1 chmod 命令符号表示法
| 符号 | 说明 |
|---|---|
| u | "user"的简写,意思是文件或目录的所有者 |
| g | 用户组 |
| o | "others"的简写,意思是其他所有的人 |
| a | "all"的简写,是"u", "g"和“o”三者的联合 |
如果没有指定字符,则假定使用”all”。执行的操作可能是一个“+”字符,表示加上一个权限, 一个“-”,表示删掉一个权限,或者是一个“=”,表示只有指定的权限可用,其它所有的权限被删除
chmod 755 a.txt // 将文件权限设置成755
chmod a+x a.txt // 所用组都赋予x权限
chmod u+x,go=rw a.txt //给文件拥有者执行权限并给组和其他人读和执行的权限。多种设定可以用逗号分开
umask - 设置默认权限¶
su - 切换用户¶
sudo - 使用其他身份执行命令¶
sudo su - // 切换到root用户
sudo nginx -s reload // 以root用户身份执行nginx配置重载
sudo -u www-data top // 已指定用户身份(www-data)执行top命令
chown - 更改文件所有者和用户组¶
chown tinker a.txt // 将a.txt文件所有者改为tinker,文件用户组不变
chown tinker:tony a.txt // 将a.txt文件所有者改为tinker,文件用户组改为tony
chown :tony a.txt //将a.txt文件的用户组改为tony,所有者不变
chown tinker: a.txt // 将a.txt文件所有者改成tinker,用户组改为tinker登录系统时候,所属的用户组
chown -aG sudo tinker // 将用户加入sudo用户组
password - 更改用户密码¶
ps - 报告当前进程快照¶
ps aux // 查看当前允许进程
ps -ef | more // 查看当前运行的所有进程(包含进程的父进程信息)
ps -A --sort=-rss -o comm,pmem,pcpu | uniq -c |head -15 // 按进程内存占用大小,从大到小来排序。RSS表示实际分配的内存大小
ps -eo comm,pmem,pcpu --sort=-%cpu | head -10 // 按进程消耗cpu资源大小,从小大到大排序。--sort=-%cpu表示使用从大到小。-e和-A是一样的。
ps -c nginx --no-header | wc -l // 统计nginx进程数量。--no-header表示不答应头部
ps -axjf // 打印进程树
ps -p 1924 -o lstart // 查看进程ID等于1924的进程创建时间
ps -eLf | grep java | wc -l // 查看java线程数
ps内容说明
| 项 | 说明 |
|---|---|
| %CPU | 进程的cpu占用率 |
| %MEM | 进程的内存占用率 |
| VSZ | 进程所使用的虚存的大小 |
| RSS | 进程使用的驻留集大小或者是实际内存的大小 |
| TTY | 与进程关联的终端(tty) |
| STAT | 进程的状态 |
| START | (进程启动时间和日期) |
| TIME | (进程使用的总cpu时间) |
| COMMAND | (正在执行的命令行命令) |
| NI | (nice)优先级 |
| PRI | 进程优先级编号 |
| PPID | 父进程的进程ID(parent process id) |
| SID | 会话ID(session id) |
| WCHAN | 进程正在睡眠的内核函数名称;该函数的名称是从/root/system.map文件中获得的 |
| FLAGS | 与进程相关的数字标识 |
STAT值有:
| 值 | 含义 |
|---|---|
| R | running正在运行或准备运行 |
| S | sleeping休眠 |
| I | idle空闲 |
| Z | 僵死 |
| D | 不可中断的睡眠,通常是I/O |
| P | 等待交换页 |
| W | 换出,表示当前页面不在内存 |
| N | 低优先级任务 |
| T | terminate终止 |
| W | 进入内存交换(从内核2.6开始无效) |
| < | 高优先级 |
| L | 有些页被锁进内存 |
| + | 位于后台的进程组 |
| l | 多线程,克隆线程 |
| s | 包含子进程 |
top - 动态查看进程¶
top命令是常用的性能分析工具,能够实时显示系统中各个进程的资源占用状况
常见选项列表:
| 选项 | 说明 |
|---|---|
| -u user | 查看特定用户user的进程 |
| -c | 显示完整命令 |
| -H | 以线程模式执行 top,等同于交互式命令 H。默认情况下以进程模式执行 top,进程模式下,一个进程下的所有线程归总后显示一行 |
| -i | 不显示任何闲置(idle)或无用(zombie)的进程 |
| -p pid | 只显示指定进程 ID 的进程信息。可以指定多个 PID,最多为 20 个,格式为 -p PID1,PID2,PID3...。特别地,pid 为 0 表示 top 命令本身。如果想显示所有进程信息,无需关闭 top 命令,只需要执行交互式命令 =、u 或 U 即可。 |
上图说明:
第一行:时间相关和任务队列信息
- 05:18:41 当前时间
- up 5 days, 14:23 运行时间,分钟
- 1 user 当前等于用户数
-
load average: 0.03, 0.01, 0.00 当前系统负载,跟uptime命令一样,说明如下:
三个值是系统在1分钟、5分钟、15分钟内平均负载。系统平均负载被定义为在特定时间间隔内运行队列中的平均进程数。
对于系统负载,我们可以类比成高速公路上跑汽车情况。单核CPU服务器,满负载情况,也就是车几乎挨着车,但不堵塞,秩序正常,此时的负载值是1。如果多核CPU情况,满负载值就会是 n * 1,其中n为cpu核数。
第二行:进程信息统计数据
- 138 total 总的进程数
- 2 running 正在运行的进程数
- 136 sleeping 休眠的进程数
- 0 stopped 停止的进程数
- 0 zombie 僵尸进程数
第三行:CPU统计数据
- 0.0 us 用户空间占用CPU百分比
- 0.8 sy 内核空间占用CPU百分比
- 0.0 ni 用户进程空间内改变过优先级的进程占用CPU百分比
- 99.2 id 空闲CPU时间百分比,如果这个值过低,表明系统CPU存在瓶颈
- 0.0 wa 等待I/O的CPU时间百分比,如果这个值过高,表明IO存在瓶颈
- 0.0 hi 硬中断(Hardware IRQ)占用CPU百分比
- 0.0 si 软中断(Software IRQ)占用CPU百分比
- 0.0 st 虚拟机(虚拟化技术)占用百分比
第四行:物理内存的统计数据
- 1016076 total 物理内存总量
- 146564 free 空闲内存总量
- 482980 used 已使用的物理内存总量
-
386532 buff/cache 用作内核缓存的内存量
注意:free 内存表示尚未被内核占用的空闲内存,但是被内核占用用于 buffer 和 cache 的内存,实际上是可以被进程使用的,内核并不把这些可被重新使用的内存算到 free 中,因此在 Linux 上 free 内存会越来越少,但不用为此担心
第五行: 交换分区(即虚拟内存)的统计数据
- 1048572 total 交换区总量
- 509624 free 空闲交换区总量
- 538948 used 已使用的交换区总量
- 332548 avail Mem 实际可用物理内存总量
第六行为空行
前面五行称为汇总区(Summary Area)。从第7行开始,显示各个进程的状态信息,称为任务区(Task Area),各列含义如下:
| 列名 | 说明 |
|---|---|
| PID | 进程id |
| USER | 进程所有者 |
| PR | 进程优先级,范围为0-31,数值越低,优先级越高 |
| NI | nice值。范围-20到+19,用于调整进程优先级,新的进程优先级 PR(new)=PR(old)+nice,所以nice负值表示高优先级,正值表示低优先级 |
| VIRT | 进程使用的虚拟内存总量,单位 KB |
| RES | Resident Memory Size,进程使用的、未被换出的物理内存大小,单位 KB |
| SHR | 共享内存大小,单位 KB |
| S | 进程状态。D=不可中断的睡眠状态 R=运行 S=睡眠 T=停止 t=跟踪 Z=僵尸进程 |
| %CPU | 上次更新到现在的 CPU 时间占用百分比。注意,在多核或多 CPU 环境中,如果进程是多线程的,而 top 不是在线程模式下运行的,该值由多个核的值累加,可能会大于 100% |
| %MEM | 进程使用的物理内存百分比 |
| TIME+ | 进程使用的 CPU 时间总计,单位 1/100 秒 |
| COMMAND | 进程名称(命令名/命令行) |
交换模式下命令
| 命令 | 结果 |
|---|---|
| P | 进程列表按CPU使用大小降序排序 |
| M | 进程列表按内存使用大小降序排序 |
| T | 进程列表按照累计时间大小降序排列 |
| N | 进程列表按照PID排序 |
| b | 进入高亮模式,行和列的背景高亮 |
| x | 高亮被选中排序的列,top命名默认使用cpu列表排序的,默认cpu列会高亮 |
| y | 高亮显示正在运行的任务,即某一行进程 |
| > | 右移选择排序列 |
| < | 左移选择排序列 |
| k | 终止一个进程。默认的终止的信号是15 |
| o | 根据列来过滤进程。 按下等号(=)会清除掉过滤条件 |
| E | 扩增(Extend)汇总区内存显示单位,从 KB、MB、GB、TB、PB 到 EB 循环切换 |
| e | 扩增(Extend)任务区内存显示单位,从 KB、MB、GB、TB、PB 到 EB 循环切换 |
| H | 以线程(tHread)模式展示任务区,每个线程单独显示一行。默认是进程模式 |
| u | 显示指定用户的进程 |
| c | 显示命令的绝对路径 |
| q | 退出top交换模式 |
| R | 反转排序 |
| m | 切换显示内存显示方式,以内存条占比形式显示 |
| k | 杀死(Kill)指定进程,默认信号为15(SIGTERM) |
| 1 | 显示或隐藏每个 CPU 核心的使用信息,即影响第三行 CPU 信息显示方式 |
| L | 在进程列表中搜索字符串 |
| = | 显示所有进程信息。可用打破-p选项,交互命令o只显示部分进程,恢复默认进程列表 |
| f | 进入字段管理窗口管理显示的字段,前面带星号的字段是默认显示的 |
交互命令o或O的筛选器格式:
<字段名><运算符><选择值>
运算符可以是=、<、>。具体示例比如COMMAND=nginx,表示筛选COMMAND是nginx的进程。%CPU>3表示cpu负载大于3进程。%CPU>0.0筛选cpu负载大于0的进行,注意是0.0,不能是0
jobs – 列出任务¶
bg – 把任务放到后台执行¶
fg – 把任务放到前台执行¶
kill - 给进程发送信号¶
kill [-signal] PID...
如果在命令行中没有指定信号,那么默认情况下,发送 TERM(终止)信号。
表4.2 常用信号
| 编号 | 名字 | 含义 |
|---|---|---|
| 1 | HUP | 挂起。许多守护进程也使用这个信号,来重新初始化。这意味着,当发送这个信号到一个守护进程后, 这个进程会重新启动,并且重新读取它的配置文件 |
| 2 | INT | 中断。实现和 Ctrl-c 一样的功能,由终端发送。通常,它会终止一个程序 |
| 3 | QUIT | 退出 |
| 9 | KILL | 杀死。这个信号很特别。鉴于进程可能会选择不同的方式,来处理发送给它的信号,其中也包含忽略信号,这样呢,从不发送Kill信号到目标进程。而是内核立即终止 这个进程。当一个进程以这种方式终止的时候,它没有机会去做些“清理”工作,或者是保存劳动成果。 因为这个原因,把 KILL 信号看作杀手锏,当其它终止信号失败后,再使用它 |
| 11 | SEGV | 段错误。如果一个程序非法使用内存,就会发送这个信号。也就是说, 程序试图写入内存,而这个内存空间是不允许此程序写入的 |
| 15 | TERM | 终止。这是 kill 命令发送的默认信号。如果程序仍然“活着”,可以接受信号,那么这个信号终止 |
| 18 | CONT | 继续。在停止一段时间后,进程恢复运行 |
| 19 | STOP | 停止。这个信号导致进程停止运行,而没有终止。像 KILL 信号,它不被 发送到目标进程,因此它不能被忽略 |
| 20 | TSTP | 终端停止。当按下 Ctrl-z 组合键后,终端发送这个信号。不像 STOP 信号, TSTP 信号由目标进程接收,且可能被忽略 |
| 28 | WINCH | 改变窗口大小。当改变窗口大小时,系统会发送这个信号。 一些程序,像 top 和 less 程序会响应这个信号,按照新窗口的尺寸,刷新显示的内容 |
按下ctrl+c组合键时产生SIGINT信号(中断信号), ctrl+\产生SIGQUIT信号(退出信号),ctrl+d是默认的文件结束符
killall - 给多个进程发送信号¶
killall [-u user] [-signal] name...
给匹配特定程序或用户名的多个进程发送信号
pstree - 显示树形结构进程列表¶
pgrep - 查看进程id¶
pmap¶
5. 文件查找¶
locate - 通过名字来查找文件¶
locate命令其实是“find -name”的另一种写法,但是要比后者快得多,原因在于它不搜索具体目录,而是搜索一个数据库(/var/lib/locatedb),这个数据库中含有本地所有文件信息。Linux系统自动创建这个数据库,并且每天自动更新一次,所以使用locate命令查不到最新变动过的文件。为了避免这种情况,可以在使用locate之前,先使用updatedb命令,手动更新数据库。
whereis - 搜索程序名¶
whereis命令只能用于程序名的搜索,而且只搜索二进制文件(参数-b)、man说明文件(参数-m)和源代码文件(参数-s)。如果省略参数,则返回所有信息。
which - 查命令¶
which命令的作用是,在PATH变量指定的路径中,搜索某个系统命令的位置,并且返回第一个搜索结果。也就是说,使用which命令,就可以看到某个系统命令是否存在,以及执行的到底是哪一个位置的命令。
find – 强大的查找命令¶
find ~ | wc -l // 统计家目录文件数
find ~ -type d | wc -l // 统计家目录下目录数量
find ~ -type f | wc -l // 统计家目录下文件数量
find ~ -type f -name "\*.JPG" -size +1M | wc -l // 查找所有文件名匹配 通配符模式“*.JPG”和文件大小大于1M 的文件
find ~ -type f -name '*.BAK' -delete // 删除扩展名为“.BAK”(这通常用来指定备份文件) 的文件
find ~ -type f -name '*.BAK' -print // 查看找到的文件,find默认是-print,即找到的每一行一换行符结束,-print0找到的每一行以null字符('\0')结束
find . -name 'index.php' -print0 | hd
find . -name 'index.php' -print | hd // 比较print和print0区别
find ~ -type f -name 'foo*' -exec ls -l '{}' ';' // {}是当前路径名的符号表示,分号是要求的界定符 表明命令结束。
find ~ -type f -name 'foo*' -ok ls -l '{}' ';' // 使用 -ok 行为来代替 -exec,在执行每个指定的命令之前,会提示用户
find ~ -type f -name 'foo*' -exec ls -l '{}' + // 把末尾的分号改为加号,就激活了find命令的一个功能,
// 把搜索结果结合为一个参数列表, 然后执行一次所期望的命令
find playground -type f -name 'file-A' | wc -l // 查找名字为file-A的文件
find playground \( -type f -not -perm 0600 \) -or \( -type d -not -perm 0700 \)
find playground \( -type f -not -perm 0600 -exec chmod 0600 '{}' ';' \)
-or \( -type d -not -perm 0711 -exec chmod 0700 '{}' ';' \)
find ~ -empty // 查找home目录下的所有空文件
find ~ -type f -size 0 // 跟上面一条命令功能一样
find ~ -type d -empty // 查找所有空目录
find ~ -iname "hello.php" // 查找hello.php文件
find . -not -name ".sh" -maxdepth 1 // 查找所有非sh文件,-not可换成!
find . -mtime 7 -type f // 搜索最近7天修改过的文件。
//-atime:访问时间 (单位是天,分钟单位则是-amin, -mtime:修改时间(内容被修改),-ctime:变化时间 (元数据或权限变化)
grep - 根据文件内容查找文件¶
grep选项:
| 选项 | 说明 |
|---|---|
| -A | 输出当前匹配内容的后面几行内容 |
| -B | 输入出当前匹配内容的前面几行内容 |
| -C | 输出当前匹配内容的前后几行内容 |
| -r | 递归查找当前目录下的文件的匹配行 |
| -i | 不区分大小的查找当前文件内容 |
| -v | 查找不匹配内容的行 |
| --color | 是否高亮显示匹配的内容,auto-自动,alawys-每次,可以通过环境变量GREP_OPTIONS配置 |
grep -i "hello" hello.php // 在hello.php里面不区分大小写的查找hello
grep -A 3 -i "hello" // 输出成功匹配的行,以及该行之后的三行,-B选项之前
grep -r "hello" dir1/ // 递归查找dir1目录下文件的匹配行
grep --color=always hello hello.php | more // 此时通过more管道时候也会高亮显示
grep --color=always abc file.txt | less -R // 此时通过less管道是也会高亮显示
grep -v php file.txt // 查找不包含php的行
grep -v -e php -e golang file.txt // 查找不包含php和golang的行
6. 归档与备份¶
gzip – 压缩或者展开文件¶
执行gzip命令时,则原始文件的压缩版会替代原始文件。相对应的gunzip程序被用来把压缩文件复原为没有被压缩的版本。
表6.1 giz选项
| 选项 | 说明 |
|---|---|
| -c | 把输出写入到标准输出,并且保留原始文件。也有可能用--stdout 和--to-stdout 选项来指定 |
| -d | 解压缩。正如 gunzip 命令一样。也可以用--decompress 或者--uncompress 选项来指定 |
| -r | 若命令的一个或多个参数是目录,则递归地压缩目录中的文件。也可用--recursive 选项来指定 |
| -t | 测试压缩文件的完整性。也可用--test 选项来指定 |
| -v | 显示压缩过程中的信息。也可用--verbose 选项来指定 |
gzip foo.txt // 压缩foo.txt
ls -l /etc | gzip > foo.txt.gz // 创建了一个目录列表的压缩文件
gzip -d foot.txt.gz // 解压*.gz文件
gzip -tv foo.txt.gz // 测试文件的完整性
gunzip -c foo.txt | less // 不必指定gz拓展名,默认就是
bzip2 - 压缩文件¶
由 Julian Seward 开发,与 gzip 程序相似,但是使用了不同的压缩算法, 舍弃了压缩速度,而实现了更高的压缩级别。在大多数情况下,它的工作模式等同于 gzip。 由 bzip2 压缩的文件,用扩展名 .bz2 来表示
tar - 打包文件¶
在类 Unix 的软件世界中,这个 tar 程序是用来归档文件的经典工具。它的名字,是 tape archive 的简称,揭示了它的根源,它是一款制作磁带备份的工具。而它仍然被用来完成传统任务, 它也同样适用于其它的存储设备。我们经常看到扩展名为 .tar 或者 .tgz 的文件,它们各自表示“普通” 的 tar 包和被 gzip 程序压缩过的 tar 包。一个tar包可以由一组独立的文件,一个或者多个目录,或者 两者混合体组成
表6.2 tar操作模式
| 操作模式 | 说明 |
|---|---|
| -c | 为文件和/或目录列表创建归档文件 |
| -x | 抽取归档文件 |
| -r | 追加具体的路径到归档文件的末尾 |
| -t | 列出归档文件的内容 |
tar -cvf /path/to/foo.tar /path/to/foo/ // 创建一个包
tar -xvf foo.tar // 抽取一个包
tar -czvf /path/to/foo.tgz /path/to/foo/ // 创建.gz归档文件
tar -xzvf /path/to/foo.tgz // 抽取.gz归档文件
tar -ztvf /path/to/foo.tgz // 查看.gz文档文件的文件列表
tar -cjvf /path/to/foo.tgz /path/to/foo/ // 创建.bz2归档文件
tar -xjvf /path/to/foo.tgz // 抽取.bz2归档文件
tar -jtvf /path/to/foo.tgz // 查看.bz2归档文件的文件列表
rsync - 同步目录和文件¶
rsync options source destination
这里 source 和 destination 是下列选项之一: * 一个本地文件或目录 * 一个远端文件或目录,以[user@]host:path 的形式存在 * 一个远端 rsync 服务器,由 rsync://[user@]host[:port]/path 指定
注意 source 和 destination 两者之一必须是本地文件。rsync 不支持远端到远端的复制
rsync -av --delete /etc /home /usr/local /media/BigDisk/backup
// 备份文件到backup目录。--delete来删除可能在备份设备中已经存在但却不再存在于源设备中的文件
rsync -av --delete --rsh=ssh /etc /home /usr/local remote-sys:/backup
// --rsh=ssh 选项,其指示rsync使用ssh程序作为它的远程 shell。
rsync -avzP --progress ~/Documents/static_resource/static/* tinker@10.255.1.174:/wwwroot/static
7. 文本处理¶
cat - 连接文件并输出到标准输出¶
sort - 文本排序¶
表7.1 sort常见选项
| 选项 | 描述 |
|---|---|
| -b | 默认情况下,对整行进行排序,从每行的第一个字符开始。这个选项导致sort程序忽略 每行开头的空格,从第一个非空白字符开始排序 |
| -f | 让排序不区分大小写 |
| -n | 基于数字值大小进行排序,而不是字母值 |
| -r | 按相反顺序排序。结果按照降序排列,而不是升序 |
| -k | -k=field1[,field2],对从field1到field2之间的字符排序,而不是整个文本行。看下面的讨论 |
| -m | 把每个参数看作是一个预先排好序的文件。把多个文件合并成一个排好序的文件,而没有执行额外的排序 |
| -o | 把排好序的输出结果发送到文件,而不是标准输出 |
| -t | 定义域分隔字符。默认情况下,域由空格或制表符分隔 |
sort > foo.txt // 将标准输入内容排序好后存入到foo.txt文件
ls -l /usr/bin | sort -nr -k 5 | head // 将/usr/bin目录下文件按大小排序
sort file1.txt file2.txt file3.txt > final_sorted_list.txt // 合并有序文件
sort -k 1,1 -k 2n -k 3.7n foo.txt
// 多字段排序,对第一个字段执行字母排序,第二个字段执行数值排序,第三个字段的第七个字符按数值排序
sort -t ':' -k 7 /etc/passwd | head // passwd文件的分隔符是:, 按照第七个字段分割
uniq - 显示或省略重复行¶
uniq 只会删除相邻的重复行,常常配合sort使用,排序后然后处理重复行
表7.2 uniq常用选项
| 选项 | 说明 |
|---|---|
| -c | 输出所有的重复行,并且每行开头显示重复的次数 |
| -d | 只输出重复行,而不是特有的文本行 |
| -f n | 忽略每行开头的 n 个字段,字段之间由空格分隔。不同于sort 程序,uniq 没有选项来设置备用的字段分隔符 |
| -i | 在比较文本行的时候忽略大小写 |
| -s n | 跳过(忽略)每行开头的 n 个字符 |
| -u | 只是输出独有的文本行。这是默认的 |
cut - 从每行中删除文本区域¶
表7.3 cut常用选项
| 选项 | 说明 |
|---|---|
| -c char_list | 从文本行中抽取由 char_list 定义的文本。这个列表可能由一个或多个逗号 分隔开的数值区间组成 |
| -f field_list | 从文本行中抽取一个或多个由 field_list 定义的字段。这个列表可能 包括一个或多个字段,或由逗号分隔开的字段区间 |
| -d delim_char | 当指定-f 选项之后,使用 delim_char 做为字段分隔符。默认情况下, 字段之间必须由单个 tab 字符分隔开 |
| --complement | 抽取整个文本行,除了那些由-c 和/或-f 选项指定的文本 |
/* 例如文件a.txt内容格式如下:
tinker:12/07/2017:complete task a
jack:25/09/2017:complete task b
*/
cut -d: -f 2 a.txt | cut -c 7-10 // 输出年份
diff - 逐行比较文件¶
tr - 翻译或删除字符¶
echo "lowercase letters" | tr a-z A-Z // 小写转大写
echo "lowercase letters" | tr [:lower:] [:upper:] // 小写转大写
sed - 文本的流编辑器¶
echo "front" | sed 's/front/back/' // 输出back
sed -n "/string/p" logFile // 打印包含string的行
sed -n '5,10p' /etc/passwd // 查看文件5到10行
sed -n '3,5{=;p}' logFile // 打印3到5行,并且打印行号
sed '/^$/d' file // 删除空行
sed -i "s/原字符串/新字符串/g" `grep 原字符串 -rl 所在目录` // 替换目录下字符串
sed -n '/script_filename =/{n;p}' www.log.slow // 获取匹配行的下一行内容
awk - 文本分析工具¶
awk是超强大的文本分析工具。不仅仅是linux系统中的一个命令,而且是一种编程语言
awk指令是由模式,动作,或者模式和动作的组合组成。awk处理文件按照一行行来处理,默认每行按照空格进行分割
awk使用方法: awk 'pattern { action } '文件 pattern即模式,用来筛选符合一定规则的行 action即动作,用来做相应处理,通常是print
awk '{print $2,$5;}' a.txt // 打印指定的2,5字段
ps aux | grep mysql | grep -v grep |awk '{print $2}' | xargs kill -9 // 杀掉mysql进程
awk '{print $7}' access.log | uniq -c | sort -nr | head -n10 // 访问最多的10个url
cat /proc/meminfo | awk '/^MemTotal/{ers/{j=$0}/^Cached/{k=$0}END{printf("%s\n%s\n%s\n%s\n", h,i,j,k)}' // 查看内存信息
awk -F '[][]' '{print $3}' file // []作为分隔符
awk '$9 > 500 {print $0}' access.log | wc -l // 统计nginx访问日志里面状态码大于等于500的行数
awk -F\" '$2 ~ "^GET /api" {print $0}' access.log // 打印nginx访问日志里面请求方法为GET,url已/api开头的记录
awk '{if(NR>=10 && NR<=20) print $0}' access.log // 打印10到20行
awk '/NR>=10, NR<=20/{print $0}' access.log // 打印10到20行。基于范围模式匹配模式
awk '/script_filename =/{getline a;print a;}' www.log.slow // 获取匹配行的下一行内容。getline是读取下一行内容并复制给变量a,接着打印出来。其中变量a可以省略
head - 显示开头文字行¶
tail¶
vim¶
vim +10 file1.txt // 打开文件并调到第10行
vim +/search_term file2.txt // 打开文件并调到第一个匹配的行
vim -R /etc/passwd // 只读模式打开文件
替换字符用法
语法:[addr]s/源字符串/目的字符串/[option]
:s/vivian/sky/ 替换当前行第一个 vivian 为 sky
:s/vivian/sky/g 替换当前行所有 vivian 为 sky
:n,$s/vivian/sky/g 替换第 n 行开始到最后一行中每一行所有 vivian 为 sky(n 为数字,若 n 为 .,表示从当前行开始到最后一行)
:%s/vivian/sky/(等同于 :g/vivian/s//sky/)替换每一行的第一个 vivian 为 sky
:%s/vivian/sky/g(等同于 :g/vivian/s//sky/g)替换每一行中所有 vivian 为 sky
| 参数 | 说明 |
|---|---|
| [addr] | 表示检索范围,省略时表示当前行。"1,20" :表示从第1行到20行;"%":表示整个文件,同"1,$";". ,$" :从当前行到文件尾; |
| s | 表示替换操作 |
| [option] | 表示操作类型:g 表示全局替换;c 表示进行确认;p 表示替代结果逐行显示(Ctrl + L恢复屏幕);省略option时仅对每行第一个匹配串进行替换;如果在源字符串和目的字符串中出现特殊字符,需要用”\”转义 |
xargs¶
find . -name '*.sh' -maxdepth 1 | xargs -I {} ls '{}' // -I指定替换字符
ls -al | xargs -n 10 rm // 每10做一个分组
find . -name "*.html" -print0 | xargs -0 rm -rf // xargs 默认是以空白字符 (空格,tab,换行符) 做分割,-0表明是使用null字符('')进行分割,可以解决文件名称中出现空格的问题
ls|grep {关键字} | xargs -n 10 rm -fr
wc - 统计行和字符的工具¶
split - 切割文件¶
split用于将文件切割成多个小文件
表7.4 split选项
| 选项 | 说明 |
|---|---|
| b | 每一个新文件的大小,单位为byte |
| C | 每一个文件单行的最大byte数 |
| d | 新文件名以数字作为后缀 |
| l | 每一个新文件的列数大小 |
split -b 10k date.file // 将文件分割成大小为10KB的小文件
split -b 10k date.file -d -a 3 new_file_name // 将文件分割成大小为10KB的小文件, 新文件名以数字作为后缀
split -l 10 date.file // 把文件分割成每个包含10行的小文件
8. 网络管理¶
ping - 发送 ICMP ECHO_REQUEST 软件包到网络主机¶
ping 命令发送一个特殊的网络数据包,叫做IMCP ECHO_REQUEST,到 一台指定的主机。大多数接收这个包的网络设备将会回复它,来允许网络连接验证,反应连接速度。
注意:大多数网络设备(包括 Linux 主机)都可以被配置为忽略这些数据包。通常,这样做是出于网络安全 原因,部分地遮蔽一台主机免受一个潜在攻击者地侵袭。配置防火墙来阻塞IMCP流量也很普遍。
traceroute - 打印到一台网络主机的路由数据包¶
显示从本地到指定主机 要经过的所有路由
traceroute www.cyub.vip
netstat - 网络查看工具¶
表7.5 netstat常见选项
| 选项 | 说明 |
|---|---|
| a | 显示所有连线中的Socket |
| l | 显示监控中的服务器的Socket |
| n | 直接使用ip地址,而不通过域名服务器 |
| p | 显示正在使用Socket的程序识别码和程序名称 |
| r | 显示Routing Table |
| t | 显示TCP传输协议的连线状况 |
| u | 显示UDP传输协议的连线状况 |
| e | 显示网络其他相关信息 |
| i | 显示网络界面信息表单 |
| o | 显示计时器 |
netstat -ie // 查看系统网络接口
netstat -nr // 查看路由
netstat -an|awk '/^tcp/{++S[$NF]}END{for (a in S)print a,S[a]}' // 查看tcp链接数
netstat -pant |grep ":80"|awk '{print $5}' | awk -F: '{print $1}'|sort|uniq -c|sort -nr // 查看连接数最多的ip
netstat -tunlp | grep 22 // 查看22端口情况
sudo netstat -pnt | grep :80 | wc -l // 粗略估计访问80端口人数
ftp - 因特网文件传输程序¶
wget - 非交互式网络下载器¶
ssh - SSH 客户端¶
nslookup - 查看dns解析¶
dig - 查看dns解析¶
dig www.cyub.vip a // 查询域名的A记录,最后的a可省略
dig www.cyub.vip mx // 查询域名的mx记录,其他类型的记录有MX,CNAME,NS,PTR等,默认a记录
dig @10.255.1.174 www.cyub.vip // 指定dns服务器
dig www.cyub.vip a +tcp // dig默认使用udp协议进行查询,+tcp参数则指定tcp方式查询
dig www.cyub.vip a +trace // +trace参数将显示从根域逐级查询的过程
curl - 传输数据工具¶
curl www.cyub.vip // 查看网页内容
curl -s -o cyub.me.txt www.cyub.vip // 保存网页内容, -s表示silent,不会显示下载进度
curl -O --progress -C www.cyub.vip/file.zip // 下载文件,本地文件名称与远程服务器文件名称一样。--progress显示进度条,-C继续断点下载
curl --head(I) www.cyub.vip // 查看网页响应头
curl --data(d) "birthyear=1905&press=%20OK%20" www.cyub.vip // 以Content-Type=application/x-www-form-urlencoded形式
// POST数据,此时的数据需要urlencode处理好
curl --data-urlencode "name=I am Daniel" www.cyub.vip // 跟上一条命令类似,但数据不用预先编码处理
curl --form upload=@localfilename --form press=OK www.cyub.vip // 文件上传,Content-Type是multipart/form-data
curl --proxy proxy.cyub.vip:4321 www.cyub.vip/ // 设置代理
curl --user name:password www.cyub.vip // Basic Authentication
curl --cookie "name=tinker" www.cyub.vip // cookie
curl --dump-header headers_and_cookies www.cyub.vip
curl --header(H) "Content-Type: text/xml" --request www.cyub.vip
curl --request(X) POST www.cyub.vip // 指定请求方法
curl -v www.cyub.vip // 显示通信过程
curl --trace output.txt www.cyub.vip // 显示通信过程,内容比上一条命令详细
curl --trace-ascii dump.txt www.cyub.vip // 显示通信过程
curl还可以通过设置-w选项的时间变量来查看具体传输请求时间,常用变量如下:
| 选项 | 说明 |
|---|---|
| content_type | 请求文件的Content-Type |
| http_code | 响应状态码 |
| http_version | http版本 |
| local_ip | local ip |
| local_port | local port |
| redirect_url | 当请求没有指定-L, --location来保存重定向时候, 此变量显示重定向url |
| remote_ip | remote IP |
| remote_port | remote port |
| scheme | URL scheme |
| size_download | 请求文件的大小(单位byte) |
| size_header | 响应头的大小(单位byte) |
| speed_download | 平均下载速度(单位byte/s) |
| time_appconnect | SSL/SSH 3次握手完成时间(单位s) |
| time_connect | 与远程服务器建立连接完成时间(单位s) |
| time_namelookup | 从请求开始到DNS解析完毕所用时间(单位s) |
| time_starttransfer | 最初的网络请求被发起到从服务器接收到第一个字节前所花费时间,即TTFB(单位s) |
| time_total | 完成请求总共时间(单位s) |
注意: 某些变量高版本curl才支持, 具体参见curl man page
用法如下:
curl -o /dev/null -s -w %{http_code}:%{remote_ip}:%{time_total} http://www.cyub.vip
// 把变量写入curl-format.txt文件里面
curl -o /dev/null -s -w "@curl-format.txt" http://www.cyub.vip
// curl-format.txt
response_code: %{http_code} %{content_type}\n
client_server: %{local_ip}:%{local_port} => %{remote_ip}:%{remote_port}\n
dns_resolution: %{time_namelookup}s\n
tcp_established: %{time_connect}s\n
ssl_handshake_done: %{time_appconnect}s\n
TTFB: %{time_starttransfer}s\n
time_total: %{time_total}s\n
size_download: %{size_download}bytes\n
speed_download: %{speed_download}byte/s\n
tcpdump - 网络流量监测工具¶
tcpdump默认抓取68个字节。
tcpdump -i eth0 not port 22 // 查看eth0接口非22端口的流量
tcpdump -i eth0 -s 0 -w file.cap // 保存抓包信息到文件中
Tcpdump -r file.pcap // 读取抓包文件
tcpdump -A -r file.pcap // 以ascii吗形式显示包内容
firewall-cmd - 防火墙管理¶
FirewallD 是 iptables 的前端控制器,用于实现持久的网络流量规则。它提供命令行和图形界面
防火墙启动与关闭
systemctl start firewalld // 启动防火墙
systemctl enable firewalld // 设置开机启动
systemctl stop firewalld // 关闭防火墙
systemctl disable firewalld // 设置不开机启动
firewall-cmd用例
firewall-cmd --zone=public --add-port=80/tcp --permanent // 开放80端口
firewall-cmd --reload // 重新加载防火墙配置
firewall-cmd --state // 查看防火墙状态
firewall-cmd --get-zones // 列出支持的zone
firewall-cmd --get-services // 列出支持的服务,在列表中的服务是放行的
firewall-cmd --query-service ftp // 查看ftp服务是否支持,返回yes或者no
firewall-cmd --add-service=ftp // 临时开放ftp服务
firewall-cmd --add-service=ftp --permanent // 永久开放ftp服务
firewall-cmd --remove-service=ftp --permanent // 永久移除ftp服务
ifconfig¶
sftp¶
scp¶
nc - 网络工具中的瑞士军刀¶
nc是netcat命令别名
表7.6 nc常见选项
| 选项 | 说明 |
|---|---|
| s | 本地源ip |
| p | 进行远程连接的本地端口 |
| l | 监听模式 |
| u | udp 模式 |
| v | 显示详细 |
| w | 超时时间 |
| z | 用于扫描的Zero-I/O模式 |
| n | 直接使用ip 不使用DNS反向查询IP地址的域名 |
// 扫描192.168.33.10的20至25的端口
nc -nzv 192.168.33.10 20-25
// 使用10.1.2.3作为源地址,31337作为源端口,连接host.example.com的42端口,超时时间设为5秒
nc -s 10.1.2.3 -p 31337 -w 5 host.example.com 42
// 模拟http请求, 注意最后面需要两个换行
nc www.cyub.vip 80 '
GET / HTTP/1.1
Host: www.cyub.vip
User-Agent: self-browser
'
或者
nc www.cyub.vip 80 < request.text > index.html
// 使用HTTP proxy代理10.2.3.4:8080连接host.example.com
nc -x10.2.3.4:8080 -Xconnect host.example.com 42
// 创建和监听UNIX-domain stream socket
nc -lU /var/tmp/dsocket
9. 系统监控¶
uptime¶
htop¶
iotop¶
vmstat - 显示资源使用快照¶
显示资源快照,包括内存,交换分区和磁盘 I/O
vmstat 1 // 1秒内的资源快照
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
0 0 488124 105284 96596 429796 0 1 4 6 23 12 0 0 99 0 0
0 0 488124 105160 96596 429796 0 0 0 0 280 554 0 1 99 0 0
0 0 488124 105160 96596 429804 0 0 0 0 260 544 1 0 99 0 0
0 0 488124 105160 96596 429796 0 0 0 0 254 539 0 1 99 0 0
0 0 488124 105160 96596 429804 0 0 0 0 268 550 0 1 99 0 0
0 0 488124 105160 96600 429792 0 0 0 64 680 1456 2 3 95 0 0
1 0 488112 96972 96608 429812 0 0 0 40 824 1393 31 4 65 0 0
0 0 488112 96880 96608 429804 0 0 0 0 269 566 0 0 100 0 0
说明:
| 项 | 列 | 含义 |
|---|---|---|
| procs | r | 运行队列中进程数量,如果长期大于1,说明cpu不足,需要增加cpu |
| b | 等待IO的进程数量 | |
| memory | swpd | 使用虚拟内存大小,如果swpd的值不为0,但是SI,SO的值长期为0,这种情况不会影响系统性能 |
| free | 空闲物理内存大小(单位k) | |
| buff | 用作缓冲的内存大小 | |
| cache | 用作缓存的内存大小,如果cache的值大的时候,说明cache处的文件数多,如果频繁访问到的文件都能被cache处,那么磁盘的读IO bi会非常小 | |
| system | in | 表示在某一时间间隔中观测到的每秒设备中断数 |
| cs | 表表示每秒产生的上下文切换次数,如当 cs 比磁盘 I/O 和网络信息包速率高得多,都应进行进一步调查 | |
| swap | si | 由内存进入内存交换区数量 |
| so | 由内存交换区进入内存数量 | |
| io | bi | 每秒读取的块数(默认块的大小为1kb) |
| bo | 每秒写入的块数 | |
| cpu | us | 显示了用户方式下所花费 CPU 时间的百分比。us的值比较高时,说明用户进程消耗的cpu时间多,但是如果长期大于50%,需要考虑优化用户的程序 |
| sy | 显示了内核进程所花费的cpu时间的百分比。这里us + sy的参考值为80%,如果us+sy大于80%说明可能存在CPU不足 | |
| id | 显示了cpu处在空闲状态的时间百分比 | |
| wa | 显示了IO等待所占用的CPU时间的百分比。这里wa的参考值为30%,如果wa超过30%,说明IO等待严重,这可能是磁盘大量随机访问造成的,也可能磁盘或者磁盘访问控制器的带宽瓶颈造成的(主要是块操作)。 |
iostat - 监视系统输入输出设备和CPU的使用情况¶
常用选项:
| 选项 | 说明 |
|---|---|
| -c | 仅显示CPU使用情况 |
| -d | 仅显示设备利用率 |
| -k | 显示状态以千字节每秒为单位,而不使用块每秒 |
| -m | 显示状态以兆字节每秒为单位 |
| -p | 仅显示块设备和所有被使用的其他分区的状态 |
| -t | 显示每个报告产生时的时间 |
| -x | 显示扩展状态 |
iostat -x
Linux 4.4.0-101-generic (vagrant) 02/10/2018 _x86_64_ (1 CPU)
avg-cpu: %user %nice %system %iowait %steal %idle
0.20 0.01 0.29 0.03 0.00 99.48
Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s avgrq-sz avgqu-sz await r_await w_await svctm %util
sda 0.02 0.73 0.24 0.78 3.76 6.20 19.47 0.00 0.45 0.62 0.40 0.33 0.03
dm-0 0.00 0.00 0.22 1.18 3.55 5.39 12.77 0.00 0.44 0.60 0.42 0.24 0.03
dm-1 0.00 0.00 0.04 0.20 0.15 0.81 8.03 0.00 5.62 0.51 6.57 0.05 0.00
说明
| 标示 | 说明 |
|---|---|
| Device | 监测设备名称 |
| rrqm/s | 每秒需要读取需求的数量 |
| wrqm/s | 每秒需要写入需求的数量 |
| r/s | 每秒实际读取需求的数量 |
| w/s | 每秒实际写入需求的数量 |
| rsec/s | 每秒读取区段的数量 |
| wsec/s | 每秒写入区段的数量 |
| rkB/s | 每秒实际读取的大小,单位为KB |
| wkB/s | 每秒实际写入的大小,单位为KB |
| avgrq-sz | 需求的平均大小区段 |
| avgqu-sz | 需求的平均队列长度 |
| await | 等待I/O平均的时间(milliseconds) |
| svctm | I/O需求完成的平均时间 |
| %util | 被I/O需求消耗的CPU百分比 |
systemctl - 系统服务管理¶
**systemctl**是系统服务管理命令,它实际上将service和chkconfig这两个命令组合到一起。
| 任务 | 旧指令 | 新指令 |
|---|---|---|
| 使某服务自动启动 | chkconfig --level 3 httpd on | systemctl enable httpd.service |
| 使某服务不自动启动 | chkconfig --level 3 httpd off | systemctl disable httpd.service |
| 检查服务状态 | service httpd status | systemctl status httpd.service 服务详细信息 systemctl is-active httpd.service 仅显示是否Active |
| 显示所有已启动的服务 | chkconfig --list | systemctl list-units --type=service |
| 启动某服务 | service httpd start | systemctl start httpd.service |
| 停止某服务 | service httpd stop | systemctl stop httpd.service |
| 重启某服务 | service httpd restart | systemctl restart httpd.service |
systemctl start nfs-server.service // 启动nfs服务
systemctl enable nfs-server.service // 设置开机自启动
systemctl disable nfs-server.service // 停止开机自启动
systemctl status nfs-server.service // 查看服务当前状态
systemctl restart nfs-server.service // 重新启动某服务
systemctl list -units --type=service // 查看所有已启动的服务
systemctl list-dependencies nfs-server // 列出服务的依赖
lsof - 一切皆文件¶
lsof(list open files)是一个查看当前系统文件的工具。 在linux环境下,任何事物都以文件的形式存在,通过文件不仅仅可以访问常规数据,还可以访问网络连接和硬件。 如传输控制协议 (TCP) 和用户数据报协议 (UDP) 套接字等,系统在后台都为该应用程序分配了一个文件描述符,该文件描述符提供了大量关于这个应用程序本身的信息。
lsof /var/log/nginx/access.log // 查看哪个进程打开access.log
lsof -p `pgrep redis` // 查看进程打开的文件,参数是pid
lsof -c php-fpm7 // 查看进程打开的文件,参数是进程名称
lsof -u vagrant // 查看用户打开的文件
lsof -i:80 // 查看哪个进程监听80端口
lsof -i4 // 列出ipv4相关信息
lsof -d 1 // 列出占用文件描述符1的进程
lsof -d /tmp // 列出/tmp目录下被打开的文件
lsof -D /tmp // 递归列出/tmp目录下被打开的文件
free¶
df¶
shutdown - 系统关机¶
watch - 定时监控命令¶
strace - 跟踪程序执行¶
strace命令是一个集诊断、调试、统计与一体的工具
表9.1 strace常用选项
| 选项 | 说明 |
|---|---|
| -p | 指定跟踪进程的pid |
| -c | 统计每一系统调用的所执行的时间,次数和出错的次数等 |
| -f | 跟踪由fork调用所产生的子进程 |
| -ff | 如果提供-o filename,则所有进程的跟踪结果输出到相应的filename.pid中,pid是各进程的进程号 |
| -t | 在输出中的每一行前加上时间信息. -tt 在输出中的每一行前加上时间信息,微秒级 |
| -T | 显示每一调用所耗的时间,每个调用的时间花销现在在调用行最右边的尖括号里面 |
| -o | 将strace的输出写入文件filename,如果不指定-o参数的话,默认的输出设备是STDERR |
| -e | 指定一个表达式,用来控制如何跟踪。格式:[qualifier=][!]value1[,value2]... qualifier只能是trace,abbrev,verbose,raw,signal,read,write其中之一.value是用来限定的符号或数字.默认的 qualifier是 trace.感叹号是否定符号.例如:-eopen等价于 -e trace=open,表示只跟踪open调用.而-etrace!=open 表示跟踪除了open以外的其他调用.有两个特殊的符号 all 和 none. 注意有些shell使用!来执行历史记录里的命令,所以要使用\. |
-e选项常用正则如下: 默认的为trace=all. + -e trace=file 只跟踪有关文件操作的系统调用. + -e trace=process 只跟踪有关进程控制的系统调用. + -e trace=network 跟踪与网络有关的所有系统调用. + -e strace=signal 跟踪所有与系统信号有关的系统调用 + -e trace=ipc 跟踪所有与进程通讯有关的系统调用
strace -e open,access 2>&1 | grep your-filename // 查看打开文件情况
strace -e open php 2>&1 | grep php.ini // 查看php加载php.ini信息
strace -c >/dev/null ls // 跟踪ls命令执行情况
strace -f $(pidof php-fpm | sed 's/\([0-9]*\)/\-p \1/g') // 查看php-fpm进程情况
strace -f -tt -o /tmp/php.trace -s1024 -p `pidof php5-fpm | tr ' ' ','` // 同上
10. 其他¶
ulimit¶
linux 默认值 open files 和 max user processes 为 1024
uname¶
timedatectl - 查看和设置时间¶
timedatectl是用来查询和修改系统时间和配置的Linux应用程序。它是systemd 系统服务管理的一部分,并且允许你检查和修改系统时钟的配置。
timedatectl // 查看当前时间和时区
timedatectl set-time YYYY-MM-DD // 设置日期
timedatectl set-time HH:MM:SS // 设置时间
timedatectl list-timezones // 查看所有时区
timedatectl set-timezone 'Asia/Shanghai' // 设置时区
timedatectl set-ntp yes // 设置NTP同步,使用“no”关闭NTP同步,使用“yes”开启
nice¶
ssh-keygen¶
openssl¶
date¶
快捷键¶
| 快捷键 | 说明 |
|---|---|
| ctrl + u | 删除从开头到光标处的命令文本 |
| ctrl + k | 删除从光标到结尾处的命令文本 |
| ctrl + a | 光标移动到命令开头 |
| ctrl + e | 光标移动到命令结尾 |
| ctrl + w | 删除一个词(以空格隔开的字符串) |
| ctrl + c | 中断程序执行(产生SIGINT信号) |
| ctrl + \ | 退出程序执行(产生SIGQUIT信号) |
| ctrl + d | 默认的文件结束符 |
| ctrl + insert | 复制 |
| shift + insert | 黏贴 |
| ctrl + s | 冻结屏幕 |
| ctrl + q | 退出冻结屏幕 |
11. 相关资源¶
systemtap¶
SystemTap is a tracing and probing tool that allows users to study and monitor the activities of the computer system (particularly, the kernel) in fine detail. It provides information similar to the output of tools like netstat, ps, top, and iostat, but is designed to provide more filtering and analysis options for collected information.
SystemTap 是一种跟踪和探测工具,允许用户详细研究和监视计算机系统(特别是内核)的活动。它提供的信息类似于 netstat、ps、top 和 iostat 等工具的输出,但旨在为收集的信息提供更多过滤和分析选项。
SystemTap 的当前迭代允许在探测各种内核的内核空间事件时有多种选择。然而,SystemTap 探测用户空间事件的能力取决于内核支持(Utrace 机制),这在许多内核中是不可用的。因此,只有某些内核版本支持用户空间探测。
SystemTap 与许多命令行工具一起分发,允许您监视系统的活动。 stap 命令从 SystemTap 脚本中读取探测指令,将这些指令转换为 C 代码,构建内核模块,并将其加载到正在运行的 Linux 内核中。 staprun 命令运行 SystemTap 检测,即在交叉检测期间从 SystemTap 脚本构建的内核模块。
安装¶
安装systemtap
Systemtap还需内核相关信息来放置探针,在Red Hat系统里面叫做debug-info,而在ubuntu下叫 debug symbols, 简称dbgsym。这些包我们可以使用stap-prep命令来安装各种依赖,若安装失败,我们可以使用下面脚本来完成:
#!/bin/sh
sudo apt install -y systemtap
sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys C8CAB6595FDFF622
codename=$(lsb_release -c | awk '{print $2}') # ubuntu发行版本名称
sudo tee /etc/apt/sources.list.d/ddebs.list << EOF
deb http://ddebs.ubuntu.com/ ${codename} main restricted universe multiverse
deb http://ddebs.ubuntu.com/ ${codename}-updates main restricted universe multiverse
#deb http://ddebs.ubuntu.com/ ${codename}-security main restricted universe multiverse
deb http://ddebs.ubuntu.com/ ${codename}-proposed main restricted universe multiverse
EOF
sudo apt update
sudo apt install -y linux-image-$(uname -r)-dbgsym
sudo apt install -y linux-headers-$(uname -r)
交叉检测¶
在某些情况下公司政策可能会禁止管理员在特定机器上安装提供编译器或调试信息的 RPM 包,从而阻止 SystemTap 的部署。要解决此问题,SystemTap 允许使用交叉检测(cross-instrumentation)。首先我们要明确三个概念:
-
Instrumentation module(检测模块)
the kernel module built from a SystemTap script. The SystemTap module is built on the host system, and will be loaded on the target kernel of target system. - Host system
the system on which you compile the instrumentation modules from SystemTap scripts in order to load them on target systems. 主机系统,用于构建检测模块,非生产服务器,可称为编译机。
-
Target system
the system for which you are building the instrumentation modules from SystemTap scripts. 目标系统,用于运行检测模块,也就是每一个我们要诊断的生产服务器。
-
Target kernel
the kernel of the target system. This is the kernel on which you intend to load or run the instrumentation module. 目标系统的内核。
交叉检测流程:
- 在生产服务器安装
systemtap-runtime包 - 通过
uname -r命令获取生成服务器的内核版本 -
在编译机上面安装SystemTap,并生成检测模块 > stap -p4 -r kernel_version script -m module_name > 示例:stap -r 2.6.18-92.1.10.el5 -e 'probe vfs.read {exit()}' -m simple
-
将检测模块复制到生产服务器上面,然后执行 > staprun module_name.ko
第一个测试脚本¶
sudo stap -e 'probe begin {printf("hello world\n"); exit()}'
sudo stap -v -e 'probe vfs.read {printf("read performed\n"); exit()}'
sudo stap -e 'probe timer.s(4){rintf("hello world\n")}'
sudo stap -e 'probe syscall.open,syscall.openat { printf ("%s(%d) %s\n", execname(), pid(), pp())}'
工作原理¶
The essential idea behind a SystemTap script is to name events, and to give them handlers. When SystemTap runs the script, SystemTap monitors for the event; once the event occurs, the Linux kernel then runs the handler as a quick sub-routine, then resumes.
There are several kind of events; entering/exiting a function, timer expiration, session termination, etc. A handler is a series of script language statements that specify the work to be done whenever the event occurs. This work normally includes extracting data from the event context, storing them into internal variables, and printing results.
An event and its corresponding handler is collectively called a probe. A SystemTap script can have multiple probes.
A probe's handler is commonly referred to as a probe body.
SystemTap 基本思想是命名事件,并为它们提供处理程序。每当发生指定的事件时,内核都会将处理程序视为子例程运行,然后继续运行。有一系列的事件,例如进入或退出函数,计时器到期或整个SystemTap会话的开始和停止。处理程序是一系列脚本语言语句,用于指定事件发生时要完成的工作。这项工作通常包含从事件上下文中提取数据,将其存储到内部变量或打印结果。
SystemTap 的工作原理是将脚本翻译成C语言,执行C编译器创建一个内核模块。当模块被加载后,通过挂载到内核来激活所有的探测事件。然后,当事件发生再任何处理器上时,编译后的处理程序就运行,最终,SystemTap会话停止,Hook取消,内核模块被移除,整个过程由命令行程序stap驱动。
SystemTap scripts¶
SystemTap脚本文件拓展名称是.stp,脚本里面包含的探针(probes)格式如下:
probe event {statements}
SystemTap 运行一个探针有多个事件;多个事件由逗号 (,) 分隔。如果在单个探测器中指定了多个事件,则 SystemTap 将在发生任何指定事件时执行处理程序。
每个探针都有一个对应的语句块。此语句块括在大括号 ({ }) 中,包含每个事件要执行的语句。 SystemTap 依次执行这些语句;多个语句之间通常不需要特殊的分隔符或终止符。
SystemTap 允许您编写函数来分解出由许多探测器使用的代码。因此,您无需在多个探针中重复编写相同系列的语句,而只需将指令放在函数中,如下所示:
事件¶
-
syscall.system_call
系统调用, 比如syscall.open - syscall.system_call.return
系统调用返回时,比如syscall.open.return - vfs.file_operation
Virtual File System (VFS)操作时,比如vfs.read - vfs.file_operation.return
Virtual File System (VFS)操作返回时,比如vfs.read.return
-
kernel.function("function")
内核函数调用时,即进入内核函数时。我们可以使用通配符*,表示函数。
- kernel.function("*") 表示所有的内核函数
- kernel.function("*net/socket.c") 表示net/socket.c源文件内的所有内核函数
- kernel.function("function").return
内核函数调用完成,退出时
-
module("module").function("function")
模块内函数调用时,比如module("ext3").function("*") - module("module").function("function").return
模块内函数调用完成返回时 - begin
The startup of a SystemTap session,systemptap脚本即将运行 - end
The startup of a SystemTap session
-
timer events
定时器事件。比如每隔4s触发的事件是timer.s(4) 其他定时器事件有: - timer.ms(milliseconds) - timer.us(microseconds) - timer.ns(nanoseconds) - timer.hz(hertz) - timer.jiffies(jiffies) - timer.profile 每个CPU上周期触发的定时器
-
process("a.out").function("foo*")
a.out 中函数名前缀为foo的函数信息 - process("a.out").statement("*@main.c:200")
a.out中文件main.c 200行处的状态
列出进程a.out所有可用探点:
# vagrant@vagrant:/usr/src/linux-headers-5.4.0-81$ grep -nr 'SYSCALL_DEFINE3(open' ./
stap -l 'process("a.out").function("*")'
SystemTap functions¶
- tid() 当前线程id
- pid() 当前进程id
- uid() 当前用户id
- execname() 当前进程名称
- cpu() 当前cpu编号
- gettimeofday_s() 秒时间戳
- get_cycles(), 硬件周期计数器快照
- pp() 探测点事件名称
- ppfunc() 探测点触发的函数名称
- print_backtrace() 打印内核栈
- print_ubacktrace() 打印用户空间栈
- thread_indent() 缩进打印系统调用栈
-
target()
等于-x或者-c选项的值,对于stap script -x process_ID,target()等于process_ID。 对于stap script -c command,target()等于command - printf() 打印输出。格式化选项支持%s和%d
target()示例:
Target Variables¶
对于局部变量,我们可以根据变量名称直接访问
对于全局变量,我们可以使用@var访问:
@var("varname", "/path/to/exe/or/lib")
示例:
stap -e 'probe kernel.function("vfs_read") {
printf ("current files_stat max_files: %d\n",
@var("files_stat@fs/file_table.c")->max_files);
exit(); }'
我们可以列出可用探点和局部变量:
stap -L 'process("a.out").function("*")'
stap -L 'kernel.function("vfs_read")'
stap -L 'kernel.function("sched_getaffinity")'
systemap内置几个变量:
-
$$vars
\(\(locals和\)\)parms组合体,等效于sprintf("parm1=%x ... parmN=%x var1=%x ... varN=%x", parm1, ..., parmN, var1, ..., varN) - $$locals 局部变量 $$parms 函数参数 $$return 函数返回值
对于指针类型,上面三个变量默认都打印指针值,如果要显示指针指向的值,可以在加上\(或\)$后缀。
示例:
SystemTap Scripts基本操作¶
注释¶
if/else/while/for¶
function if_expr() {
i = 0
if (i == 1)
printf("[if] i = %d\n", i);
else
printf("[else] i = %d\n", i);
}
function while_expr() {
i = 0;
while (i != 2)
printf("[while] i = %d\n", i++);
}
function for_expr() {
for (i = 0; i < 2; i++)
printf("[for] i = %d\n", i);
}
示例:
global countread, countnonread
probe kernel.function("vfs_read"),kernel.function("vfs_write")
{
if (probefunc()=="vfs_read")
countread ++
else
countnonread ++
}
probe timer.s(5) { exit() }
probe end
{
printf("VFS reads total %d\n VFS writes total %d\n", countread, countnonread)
}
比较操作¶
字符串¶
function str() {
uid = uid();
s_uid = sprint(uid);
f_uid = "a" . s_uid
printf("uid: %d-%s-%s\n", uid, s_uid, f_uid); // uid: 0-0-a0
// exit();
}
元组¶
global t; // 声明元组
global tpl[400]; // 声明一个400容量的元组
t["a"]++; // t["a"] 初始值默认为0, ++ 变成 1
t["a"] = 4396; // 赋值为4396
tpl["a", pid()]++; // 两个元素
tpl["b", tid()]++;
遍历(升序), 最多遍历5次
foreach([key, value] in t+ limit 5)
printf("%s: %d\n", key, value)
聚集统计¶
t["abc", 5487] <<< 2
t["abc", 5487] <<< 3
t["abc", 5487] <<< 1
具体结构如下:
t["abc",5487] @count=3 @min=1 @max=3 @sum=6 @avg=2
global reads
probe vfs.read
{
reads[execname(),pid()] <<< 1
}
probe timer.s(3)
{
foreach([var1,var2] in reads)
printf("%s (%d) : %d \n", var1, var2, @count(reads[var1,var2]))
}
Command-Line Arguments¶
我们可以通过$访问命令行参数:
更多示例¶
- 探测go应用cpu pprof时候的系统调用
probe kernel.function("do_setitimer") {
if(execname()=="pprof") {
printf("%s\n", $$params$$)
printf("%d\n", $value->it_interval->tv_sec);
}
}
- open系统调用
probe kernel.function("sys_open").call {
printf("%s call %s\n", execname(), ppfunc());
}
probe kernel.function("sys_open").return {
printf("%s call %s return\n", execname(), ppfunc());
}
- 定时器
- cpu采样
global bts;
probe timer.profile {
if (pid() == 5291)
bts[backtrace(), ubacktrace()] <<< 1
}
probe timer.s(10) {
foreach([k, u] in bts-) {
print_stack(k);
print_ustack(u);
printf("\\t%d\\n", @count(bts[k, u]));
}
exit();
}
- socket trace
#! /usr/bin/env stap
probe kernel.function("*@net/socket.c").call {
printf ("%s -> %s\n", thread_indent(1), ppfunc())
}
probe kernel.function("*@net/socket.c").return {
printf ("%s <- %s\n", thread_indent(-1), ppfunc())
}
- tcp connections
#! /usr/bin/env stap
probe begin {
printf("%6s %16s %6s %6s %16s\n",
"UID", "CMD", "PID", "PORT", "IP_SOURCE")
}
probe kernel.{function("tcp_accept"),function("inet_csk_accept")}.return? {
sock = $return
if (sock != 0)
printf("%6d %16s %6d %6d %16s\n", uid(), execname(), pid(),
inet_get_local_port(sock), inet_get_ip_source(sock))
}
- 实现类似tcpdump功能
#! /usr/bin/env stap
// A TCP dump like example
probe begin, timer.s(1) {
printf("-----------------------------------------------------------------\n")
printf(" Source IP Dest IP SPort DPort U A P R S F \n")
printf("-----------------------------------------------------------------\n")
}
probe udp.recvmsg /* ,udp.sendmsg */ {
printf(" %15s %15s %5d %5d UDP\n",
saddr, daddr, sport, dport)
}
probe tcp.receive {
printf(" %15s %15s %5d %5d %d %d %d %d %d %d\n",
saddr, daddr, sport, dport, urg, ack, psh, rst, syn, fin)
}
- timestamp()函数实现
#! /usr/bin/env stap
global start
function timestamp:long() { return gettimeofday_us() - start }
function proc:string() { return sprintf("%d (%s)", pid(), execname()) }
probe begin { start = gettimeofday_us() }
- I/O Monitoring (By Device)
#! /usr/bin/env stap
global device_of_interest
probe begin {
/* The following is not the most efficient way to do this.
One could directly put the result of usrdev2kerndev()
into device_of_interest. However, want to test out
the other device functions */
dev = usrdev2kerndev($1)
device_of_interest = MKDEV(MAJOR(dev), MINOR(dev))
}
probe vfs.{write,read}
{
if (dev == device_of_interest)
printf ("%s(%d) %s 0x%x\n",
execname(), pid(), ppfunc(), dev)
}
- Monitoring Changes to File Attributes
#! /usr/bin/env stap
global ATTR_MODE = 1
probe kernel.function("notify_change") {
dev_nr = $dentry->d_inode->i_sb->s_dev
inode_nr = $dentry->d_inode->i_ino
if (dev_nr == MKDEV($1,$2) # major/minor device
&& inode_nr == $3
&& $attr->ia_valid & ATTR_MODE)
printf ("%s(%d) %s 0x%x/%u %o %d\n",
execname(), pid(), ppfunc(), dev_nr, inode_nr, $attr->ia_mode, uid())
}
- Counting Function Calls Made
使用方式:stap functioncallcount.stp "@mm/.c"
#! /usr/bin/env stap
# The following line command will probe all the functions
# in kernel's memory management code:
#
# stap functioncallcount.stp "*@mm/*.c"
probe kernel.function(@1).call { # probe functions listed on commandline
called[ppfunc()] <<< 1 # add a count efficiently
}
global called
probe end {
foreach (fn in called-) # Sort by call count (in decreasing order)
# (fn+ in called) # Sort by function name
printf("%s %d\n", fn, @count(called[fn]))
exit()
}
学习资料¶
arm vs AArch64 vs amd64 vs x86_64 vs x86:有什么区别?¶
当涉及到 CPU 的时候,有许多术语:AArch64、x86_64、amd64、arm 等等。了解它们是什么以及它们之间的区别。
当你查看数据表或软件下载页面时是否被 ARM、AArch64、x86_64、i386 等术语混淆?这些被称为 CPU 架构,我会帮你深入了解这个计算话题。
以下的表将为你总结每个字符串所代表的意义:
| CPU 架构 | 描述 |
|---|---|
x86_64 /x86/amd64 |
64 位 AMD/英特尔 CPU 的别称 |
AArch64 /arm64/ARMv8/ARMv9 |
64 位 ARM CPU 的别称 |
i386 |
32 位 AMD/英特尔 CPU |
AArch32 /arm/ARMv1 到 ARMv7 |
32 位 ARM CPU 的别称 |
rv64gc /rv64g |
64 位 RISC-V CPU 的别称 |
ppc64le |
64 位 PowerPC CPU,小端字节序存储 |
从左到右是使用该术语来描述 CPU 架构超过其右侧其他可选用术语的偏好。
从左到右是使用该术语描述 CPU 架构的优先级,使用左侧的而不是其右侧的其他可供选择的术语。
如果你像我一样是个极客,并想要更深入地解释,请继续阅读!
概述:CPU 架构¶
通常来说,我之前列出的术语是描述 CPU 架构的。但严格讲,它们被计算机工程师视为 CPU 的 指令集架构(ISA)。
CPU 的指令集架构定义了 CPU 如何解析二进制代码中的 1 和 0。
这些 CPU 的 ISA 有几个主要的类别:
- x86(AMD/英特尔)
- ARM
- RISC-V
- PowerPC(IBM 仍在使用)
当然,还有更多种类的 CPU ISA,比如 MIPS、SPARC、DEC Alpha 等等。但我列出的这些至今仍然被广泛使用(以某种形式)。
上述列出的 ISA 主要根据 内存总线的宽度 分为至少两个子集。内存总线的宽度指的是 CPU 和 RAM 一次能传输的位数。内存总线有很多种宽度,但最常见的是 32 位和 64 位。
💡 32 位的 CPU ISA 要么是已经过时的历史产物,被留下来要么只是为了支持旧的系统,要么只运用在微控制器中。可以说,所有新的硬件都已经是 64 位的了,特别是那些面向消费者的硬件。
x86(AMD/英特尔)¶
x86 CPU 的指令集架构主要源于英特尔,因为英特尔是最初搭配 8085 微处理器创建了它。8085 微处理器的内存总线宽度为 16 位。而后来,AMD 加入了这个领域,并且一直紧随英特尔的步伐,直到 AMD 创建出了自己的超集 64 位架构,超过了英特尔。
x86 架构的子集如下:
i386:如果你拥有的是 2007 年之前的 CPU,那么这可能就是你的 CPU 架构。它是现在使用的 AMD/英特尔的 x86 架构的 32 位“版本”。x86_64/x86/amd64:这三个术语在不同的项目中可能会被交替使用。 但它们都是指 x86 AMD/英特尔架构的 64 位“版本”。无论如何,x86_64这个字符串比x86和amd64使用得更广泛(也更受欢迎)。例如,FreeBSD 项目称 64 位的 x86 架构为amd64,而 Linux 和 macOS 则称之为x86_64。
💡 由于 AMD 在创造 64 位 ISA 上超越了英特尔,所以一些项目(比如 FreeBSD)把 x86 的 64 位版本称为
amd64。但更被广泛接受的术语还是 x86_64。
对于 CPU ISA,“x86” 这个字符串是一种特殊的情况。你要知道,在从 32 位的 x86(i386)到 64 位的 x86(x86_64)的过渡过程中,CPU 制造商确保了 CPU 能够运行 32 位 和 64 位指令。所以,有时你可能会看到 x86 也被用来意指“这款产品只能运行在 64 位的计算机上,但如果该计算机能运行 32 位指令,那么你也可以在它上面运行 32 位的用户软件”。
这种 x86 的模糊性——也就是诸如能同时运行 32 位代码的 64 位处理器——其主要用于和存在于运行在 64 位处理器上的,但是允许用户运行 32 位软件的操作系统,Windows 就通过这种被称作“兼容模式”的特性运用了这种方式。
汇总一下,由 AMD 和 英特尔 设计的 CPU 有两种架构:32 位的(i386)和 64 位的(x86_84)。
其它的英特尔¶
x86_64 ISA 实际上有几个子集。这些子集都是 64 位,但它们新添加了诸如 SIMD(单指令多数据)指令等功能。
x86_64-v1:这是大多数人都熟知的基础x86_64ISA。当人们谈论x86_64时,他们通常指的就是x86_64-v1ISA。x86_64-v2:此版本新增了更多如 SSE3(流式 SIMD 扩展版本 3)之类的指令扩展。x86_64-v3:除了基础指令外,还新增了像 AVX(高级矢量扩展)和 AVX2 等指令。这些指令可以**使用高达 256 位宽的 CPU 寄存器**!如果你能够有效利用它们,就能大规模并行处理计算任务。x86_64-v4:这个版本在x86_64-v3ISA 的基础上,迭代了更多的 SIMD 指令扩展,比如 AVX256 和 AVX512。其中,AVX512 可以**使用高达 512 位宽的 CPU 寄存器**!
ARM¶
ARM 不仅是一家为 CPU ISA 制定规范的公司,它也设计并授权给其他厂商使用其 CPU 内核,甚至允许其他公司使用 ARM CPU ISA 设计自己的 CPU 内核。(最后那句话听起来就像是个 SQL 查询似的!)
你可能因为如树莓派这类的 单板计算机)(SBC)听说过 ARM。但其实 ARM 的 CPU 还广泛应用于手机中。最近,苹果从使用 x86_64 处理器转向了在其笔记本和台式机产品中使用自家设计的 ARM 处理器。
就像任一种 CPU 架构一样,ARM 基于内存总线宽度也有两个子集。
官方认定的 32 位和 64 位 ARM 架构的名称分别是 AArch32 和 AArch64。这里的 AArch 字符串代表 “Arm 架构”。这些是 CPU 执行指令时可切换的**模式**。
实际符合 ARM 的 CPU ISA 的指令规范被命名为 ARMvX,其中 X 是规范版本的代表数字。目前为止,已经有九个主要的规范版本。规范 ARMv1 到 ARMv7 定义了适用于 32 位 CPU 的架构,而 ARMv8 和 ARMv9 是适用于 64 位 ARM CPU 的规范。(更多信息在此)
💡 每个 ARM CPU 规范又有进一步的子规范。例如 ARMv8,我们有 ARMv8-R、ARMv8-A、ARMv8.1-A、ARMv8.2-A、ARMv8.3-A、ARMv8.4-A、ARMv8.5-A、ARMv8.6-A、ARMv8.7-A、ARMv8.8-A 和 ARMv8.9-A。 其中 -A 表示“应用核心”,-R 表示“实时核心”。
你可能会觉得困惑,为什么在 AArch64 正式被 ARM 认定为 64 位 ARM 架构后,有些人仍然称其为 arm64。原因主要有两点:
arm64这个名称在 ARM 决定采用AArch64之前就已经广为人知了。(ARM 的一些官方文档也将 64 位的 ARM 架构称为arm64…… 😬)- Linus Torvalds 对
AArch64这个名称表示不满。 因此,Linux 的代码库主要将AArch64称为arm64。然而,当你在系统中运行uname -m时,输出仍然是aarch64。
因此,对于 32 位 ARM CPU,你应该寻找 AArch32 这个字符串,但有时也可能是 arm 或 armv7。相似的,对于 64 位 ARM CPU,你应该找 AArch64 这个字符串,但有时也可能会是 arm64、ARMv8 或 ARMv9。
RISC-V¶
RISC-V 是 CPU 指令集架构(ISA)的一个开源规范。**但这并不意味着 CPU 自身是开源的!**这有点像以太网的情况。以太网规范是开源的,但你需付费购买网线、路由器和交换器。同样,RISC-V CPU 也要花钱购买。 :)
尽管如此,这并没有阻止人们创建并在开源许可下提供免费获取(设计上的获取,并非物理核心/SoC)的 RISC-V 核心。这是其中的一项尝试。
💡 总结一下:如果你在寻找运行于 RISC-V 消费级 CPU 上的软件,你应该寻找 “rv64gc” 这一字符串。这是许多 Linux 发行版所公认的。
像所有 CPU 架构一样,RISC-V 拥有 32 位和 64 位 CPU 架构。但由于 RISC-V 是非常新的描述 CPU ISA 的方式,大部分主流消费端或客户端的 CPU 核心一般都是 64 位的。大部分 32 位的设计都是微控制器,用于非常具体的用例。
它们的区别在于 CPU 的扩展。被称为 RISC-V CPU 的最低要求即实现“基本整数指令集”(rv64i)。
下表列出了一些扩展及其描述:
| 扩展名称 | 描述 |
|---|---|
rv64i |
64 位基本整数指令集(必须的) |
m |
乘法和除法指令 |
a |
原子指令 |
f |
单精度浮点指令 |
d |
双精度浮点指令 |
g |
别名;一组运行**通用**操作系统所需的扩展集(包括 imafd) |
c |
压缩指令 |
在 rv64i 这一字符串中,rv 表示 RISC-V,64 指的是 64 位 CPU 架构,而 i 指的是**强制性的**基本整数指令集扩展。 rv64i 之所以是一体的,因为即使 i 被认为是一种“扩展”,但它是必须的。
约定俗成的,扩展名称按上述特定顺序排列。因此,rv64g 展开为 rv64imafd,而不是 rv64adfim。
💡 还有其他一些像 Zicsr 和 Zifencei 这样的扩展,它们位于
d和g扩展之间,但我故意不列出,以避免令你感到害怕。因此,严格说来,(在写这篇文章的时候)
rv64g实际上是rv64imafdZicsrZifencei。恶魔般的笑声
PowerPC¶
PowerPC 曾是苹果、IBM 以及,摩托罗拉早期合作时代的一种流行 CPU 架构。在苹果转向英特尔的 x86 架构之前,它一直被应用于苹果的全部消费品产品线。
最初,PowerPC 采取的是大端字节序的内存排序。后来随着 64 位架构的引入,增加了使用小端字节排序的选项。这么做的目的是为了与英特尔的内存排序保持兼容(以防止软件错误),因为英特尔自始至终都一直采用的是小端字节序。有关字节序的更多内容,我可以唠叨很久,不过你可以通过阅读 这篇 Mozilla 的文档 来了解更多。
由于字节序在此也起到了一定的作用,PowerPC 共有三种架构:
powerpc:表示 32 位的 PowerPC 架构。ppc64:表示拥有**大端字节序内存排序**的 64 位 PowerPC 架构。ppc64le:表示拥有**小端字节序内存排序**的 64 位 PowerPC 架构。
目前,ppc64le 是被广泛使用的架构。
结论¶
市面上有各种各样的 CPU 架构。对于每一种架构,都有 32 位和 64 位的子集。在现有的 CPU 中,我们可以找到 x86、ARM、RISC-V 和 PowerPC 等架构。
其中,x86 是最广泛和易于获取的 CPU 架构,因为英特尔和 AMD 都采取了这种架构。此外,ARM 提供的产品几乎在手机和易于获取的单板计算机中被独占使用。
RISC-V 正在努力使硬件更广泛地被使用。我就有一款带有 RISC-V CPU 的单板计算机。 ;)
而 PowerPC 主要用于服务器,至少当前如此。
(题图:MJ/634ac7ea-b344-443a-b041-3bb3b31a956f)
via: https://itsfoss.com/arm-aarch64-x86_64/
作者:Pratham Patel 选题:lujun9972 译者:ChatGPT 校对:wxy
Linux 内核动手编译实用指南¶
一份让你深入体验最新 Linux 内核编译过程的实操指南。
出于各种原因,自行编译 Linux 内核可能引起你的兴趣。这些原因可能包括但不限于:
- 测试一个比你目前的 Linux 发行版更新的内核版本
- 采用一组不同的配置选项、驱动来构建内核
- 学习者的好奇心 :)
此指南将一步步指导你如何亲自编译 Linux 内核,包括你该运行哪些命令,为什么运行这些命令以及这些命令的执行效果。本文篇幅较长,所以请做好准备!
🚧 诸如 Ubuntu 这样的发行版提供了更简单地安装主线 Linux 内核的方式。但本教程目标是从源码手动完成所有工作。此教程需要你付出时间、耐心以及丰富的 Linux 命令行使用经验。本文更注重亲身实践的体验。不管怎么说,我仍建议你在虚拟机或备用系统中尝试此冒险,而非在你的主系统上进行。
前置准备¶
在软件领域,构建任何事物都有两个基本要求:
- 源代码
- 构建依赖
因此,作为预备环节,我们需要下载 Linux 内核的源码压缩包,并安装一些能让我们成功构建 Linux 内核的依赖项。
Linux 版本导览¶
在任何时刻,Freax Linux 内核都有四种“版本”。
Linux 的这些 “版本”,按照开发流程的顺序是:
- linux-next 树: 所有准备合并到 Linux 代码库的代码首先被合并到 linux-next 树。它代表的是 Linux 内核最新也是“最不稳定”的状态。大多数 Linux 内核开发者和测试人员使用这个来提高代码质量,为 Linus Torvalds 的后续提取做准备。请谨慎使用!
- 发布候选版(RC) / 主线版: Linus 从 linux-next 树抽取代码并创建一个初始发布版本。这个初始发布版本的测试版称为 RC(发布候选)版本。一旦 RC 版本发布,Linus 只会接受对它的错误修复和性能退化相关的补丁。基础这些反馈,Linus 会每周发布一个 RC 内核,直到他对代码感到满意。RC 发行版本的标识是
-rc后缀,后面跟一个数字。 - 稳定版: 当 Linus 觉得最新的 RC 版本已稳定时,他会发布最终的“公开”版本。稳定发布版将会维护几周时间。像 Arch Linux 和 Fedora Linux 这样的前沿 Linux 发行版会使用此类版本。我建议你在试用 linux-next 或任何 RC 版本之前,先试一试此版本。
- LTS 版本: 每年最后一个稳定版将会再维护 几年。这通常是一个较旧的版本,但它会 会积极地维护并提供安全修复。Debian 的稳定版本会使用 Linux 内核的 LTS 版版本。
若想了解更多此方面的知识,可参阅 官方文档。
本文将以当前可用的最新稳定版为例,编写此文时的 Linux 内核版本是 6.5.5。
系统准备¶
由于 Linux 内核使用 C 语言编写,编译 Linux 内核至少需要一个 C 编译器。你的计算机上可能还需要其他一些依赖项,现在是安装它们的时候了。
💡 这个指南主要聚焦于使用 GNU C 编译器(GCC)来编译 Linux 内核。但在未来的文章中(可能会深入介绍 Rust 的支持),我**可能**会介绍使用 LLVM 的 Clang 编译器作为 GCC 的替代品。
不过,请注意,MSVC 并不适用。尽管如此,我仍期待有微软的员工为此发送修补程序集。我在瞎想啥?
对于 Arch Linux 以及其衍生版本的用户,安装命令如下:
sudo pacman -S base-devel bc coreutils cpio gettext initramfs kmod libelf ncurses pahole perl python rsync tar xz
对于 Debian 以及其衍生版本的用户,安装命令如下:
sudo apt install bc binutils bison dwarves flex gcc git gnupg2 gzip libelf-dev libncurses5-dev libssl-dev make openssl pahole perl-base rsync tar xz-utils
对于 Fedora 以及其衍生版本的用户,安装命令如下:
sudo dnf install binutils ncurses-devel \
/usr/include/{libelf.h,openssl/pkcs7.h} \
/usr/bin/{bc,bison,flex,gcc,git,gpg2,gzip,make,openssl,pahole,perl,rsync,tar,xz,zstd}
下载 Linux 内核源码¶
请访问 kernel.org,在页面中寻找第一个 稳定 版本。你不会找不到它,因为它是最显眼的黄色方框哦 ;)
通过点击黄色的方框,你就可以下载 Tar 文件。同时,也别忘了下载相匹配的 PGP 签名文件,稍后我们需要用到它来验证 Tar 文件。它的扩展名为 .tar.sign。
校验 Tar 文件的完整性¶
你如何知道刚下载的 Tar 文件是否被损坏?对于个人来说,一个损坏的 Tar 文件只会浪费你的宝贵时间,如果你是在为一个组织工作,那么可能会危及到组织的安全(这时你可能还有更大的问题需要担忧,但我们并不想让所有人都产生创伤后应激障碍!)。
为了验证我们的 Tar 文件的完整性,我们需要先解压它。目前,它是使用 XZ 压缩算法压缩的。因此,我将使用 unxz 工具(其实就是 xz --decompress 的别名)来解压 .tar.xz 格式的压缩文件。
解压完成后,我们需要获取 Linus Torvalds 和 Greg KH 使用的 GPG 公开密钥。这些密钥用于对 Tar 文件进行签名。
你应该可以得到一个与我在我的电脑上看到的类似的结果:
$ gpg2 --locate-keys torvalds@kernel.org gregkh@kernel.org
gpg: /home/pratham/.gnupg/trustdb.gpg: trustdb created
gpg: key 38DBBDC86092693E: public key "Greg Kroah-Hartman <gregkh@kernel.org>" imported
gpg: Total number processed: 1
gpg: imported: 1
gpg: key 79BE3E4300411886: public key "Linus Torvalds <torvalds@kernel.org>" imported
gpg: Total number processed: 1
gpg: imported: 1
pub rsa4096 2011-09-23 [SC]
647F28654894E3BD457199BE38DBBDC86092693E
uid [ unknown] Greg Kroah-Hartman <gregkh@kernel.org>
sub rsa4096 2011-09-23 [E]
pub rsa2048 2011-09-20 [SC]
ABAF11C65A2970B130ABE3C479BE3E4300411886
uid [ unknown] Linus Torvalds <torvalds@kernel.org>
sub rsa2048 2011-09-20 [E]
在导入 Greg 和 Linus 的密钥后,我们可以使用 --verify 标志来验证 Tar 的完整性,操作如下:
如果验证成功,你应该会看到如下的输出信息:
$ gpg2 --verify linux-*.tar.sign
gpg: assuming signed data in 'linux-6.5.5.tar'
gpg: Signature made Saturday 23 September 2023 02:46:13 PM IST
gpg: using RSA key 647F28654894E3BD457199BE38DBBDC86092693E
gpg: Good signature from "Greg Kroah-Hartman <gregkh@kernel.org>" [unknown]
gpg: WARNING: This key is not certified with a trusted signature!
gpg: There is no indication that the signature belongs to the owner.
Primary key fingerprint: 647F 2865 4894 E3BD 4571 99BE 38DB BDC8 6092 693E
务必查看是否存在 gpg: Good signature 的提示,然后再继续!
💡 你可以忽略以下警告:
WARNING: This key is not certified with a trusted signature! There is no indication that the signature belongs to the owner.。我们已根据 Linus 和 Greg 的邮件地址获取了公开密钥,并无需对此警告感到担忧。
解压 Tar 文件¶
如果你顺利的进行到这里,意味着你的 Tar 文件完整性检查已经成功完成。接下来,我们将从 Tar 文件中解压出 Linux 内核的源码。
这个步骤十分简单,只需对 Tar 文件执行 tar -xf 命令,如下:
在这里,-x 选项表示解压,-f 选项则用来告诉 Tar 文件的文件名。
这个解压过程可能需要几分钟时间,你可以先放松,耐心等待一下。
配置 Linux 内核¶
Linux 内核的构建过程会查找 .config 文件。顾名思义,这是一个配置文件,用于指定 Linux 内核的所有可能的配置选项。这是必需的文件。
获取 Linux 内核的 .config 文件有两种方式:
- 使用你的 Linux 发行版的配置作为基础(推荐做法)
- 使用默认的,通用的配置
💡 也有第三种方法,也就是从零开始,手动配置每一个选项,但注意,这需要配置超过 12,000 个选项。并不推荐这种方式,因为手动配置所有选项将花费大量的时间,并且你还需要理解每个启用和禁用选项的含义。
使用发行版提供的配置¶
使用你的 Linux 发行版提供的配置是一个安全的选择。 如果你只是跟随这个指南测试一个不是你的发行版提供的新内核,那么这就是推荐的方式。
你的 Linux 发行版的 Linux 内核配置文件会在以下两个位置之一:
- 大多数 Linux 发行版,如 Debian 和 Fedora 及其衍生版,将会把它存在
/boot/config-$(uname -r)。 - 一些 Linux 发行版,比如 Arch Linux 将它整合在了 Linux 内核中。所以,可以在
/proc/config.gz找到。
💡 如果两者都有,建议使用
/proc/config.gz。这是因为它在只读文件系统中,所以是未被篡改的。
进入含有已经解压出的 Tar 文件的目录。
接着,复制你的 Linux 发行版的配置文件:
### Debian 和 Fedora 及其衍生版:
$ cp /boot/config-"$(uname -r)" .config
### Arch Linux 及其衍生版:
$ zcat /proc/config.gz > .config
更新配置文件¶
一旦完成这些步骤,接下来就需要“更新”配置文件了。因为你的发行版提供的配置很可能比你正在构建的 Linux 内核版本要旧。
💡 这同样适用于像 Arch Linux 和 Fedora 这样前沿的 Linux 发行版。 它们并不会因为有新版本可用就立刻发布更新。他们会进行一些质量控制工作,这必然会花费些时间。因此,即便是你的发行版提供的最新内核,相较于你在 kernel.org 上获取的版本也会滞后几个小版本。
要更新一个已有的 .config 文件,我们使用 make 命令搭配 olddefconfig 参数。简单解释一下,这个命令的意思是使用 旧的、默认的、配置。
这将使用“旧的配置文件”(当前保存为 .config,这是你发行版配置的一份直接副本),并检查从上一版本以来 Linux 代码库中新加的任何配置选项。如果找到任何新的、未配置 的选项,该选项的默认配置值会被使用,并会对 .config 文件进行更新。
原来的 .config 文件将被重命名为 .config.old 进行备份,并将新的更改写入至 .config 文件。
以下是我机器上的输出:
$ file .config
.config: Linux make config build file, ASCII text
$ make olddefconfig
HOSTCC scripts/basic/fixdep
HOSTCC scripts/kconfig/conf.o
HOSTCC scripts/kconfig/confdata.o
HOSTCC scripts/kconfig/expr.o
LEX scripts/kconfig/lexer.lex.c
YACC scripts/kconfig/parser.tab.[ch]
HOSTCC scripts/kconfig/lexer.lex.o
HOSTCC scripts/kconfig/menu.o
HOSTCC scripts/kconfig/parser.tab.o
HOSTCC scripts/kconfig/preprocess.o
HOSTCC scripts/kconfig/symbol.o
HOSTCC scripts/kconfig/util.o
HOSTLD scripts/kconfig/conf
.config:8593:warning: symbol value 'm' invalid for USB_FOTG210_HCD
.config:8859:warning: symbol value 'm' invalid for USB_FOTG210_UDC
#
# configuration written to .config
#
针对 Debian 及其衍生版用户¶
Debian 及其衍生版为内核模块使用一个签名证书。默认情况下,你的计算机并不包含这个证书。
我推荐关闭启用模块签名的选项。具体如下所示:
./scripts/config --file .config --set-str SYSTEM_TRUSTED_KEYS ''
./scripts/config --file .config --set-str SYSTEM_REVOCATION_KEYS ''
如果你不这么做,在后面你进行 Linux 内核构建时,可能会导致构建失败。要注意这点。
使用自定义配置¶
如果你出于学习内核开发的目的学习如何构建 Linux 内核,那你应该这样做。
🚧 请注意,偏离你的 Linux 发行版的配置可能无法在实体硬件上“正常”工作。 问题可能是特定硬件无法工作、Linux 内核无法启动等。
因此,我们只建议在虚拟机中使用。
你可以通过查看 make help 的输出 来查看 所有 可用的选项,但我们主要关注三个 make 目标:
defconfig: 默认配置。allmodconfig: 根据当前系统状态,尽可能地把项目构建为可加载模块(而非内建)。tinyconfig: 极简的 Linux 内核。
由于 tinyconfig 目标只会构建少数项目,构建时间将会缩短。我个人选择它的原因主要有:
- 检查我在代码/工具链中做的修改是否正确,以及代码是否可以编译。
- 在虚拟机中只进行少数选项的测试。
🚧 在为 ARM 或 RISC-V 机器构建 Linux 内核时,你可能需要 DTB(设备树的二进制文件)。使用
tinyconfig目标将不会启用构建 DTB 的选项,你的内核很可能无法启动。当然,你可以用 QEMU 在没有任何 DTB 的情况下启动 Linux 内核。但这篇文章并不会聚焦在此。或许你可以通过评论,让我在之后的时间里覆盖这个话题 ;)
除非你确切地知道自己在做什么,否则你应当使用 defconfig 目标。 以下是我在我的电脑上运行的效果:
$ make defconfig
HOSTCC scripts/basic/fixdep
HOSTCC scripts/kconfig/conf.o
HOSTCC scripts/kconfig/confdata.o
HOSTCC scripts/kconfig/expr.o
LEX scripts/kconfig/lexer.lex.c
YACC scripts/kconfig/parser.tab.[ch]
HOSTCC scripts/kconfig/lexer.lex.o
HOSTCC scripts/kconfig/menu.o
HOSTCC scripts/kconfig/parser.tab.o
HOSTCC scripts/kconfig/preprocess.o
HOSTCC scripts/kconfig/symbol.o
HOSTCC scripts/kconfig/util.o
HOSTLD scripts/kconfig/conf
*** Default configuration is based on 'defconfig'
#
# configuration written to .config
#
修改配置¶
无论你是使用 Linux 发行版的配置并更新它,还是使用 defconfig 目标创建新的 .config 文件,你都可能希望熟悉如何修改这个配置文件。最可靠的修改方式是使用 menuconfig 或 nconfig 目标。
这两个目标的功能是相同的,只不过提供给你的界面有所不同。这是这两者间唯一的区别。我个人更偏向于使用 menuconfig 目标,但近来我发现 nconfig 在搜索选项时似乎更具直观性,所以我逐渐转向使用它。
首先,带着 menuconfig 目标运行 make 命令:
$ make menuconfig
HOSTCC scripts/kconfig/mconf.o
HOSTCC scripts/kconfig/lxdialog/checklist.o
HOSTCC scripts/kconfig/lxdialog/inputbox.o
HOSTCC scripts/kconfig/lxdialog/menubox.o
HOSTCC scripts/kconfig/lxdialog/textbox.o
HOSTCC scripts/kconfig/lxdialog/util.o
HOSTCC scripts/kconfig/lxdialog/yesno.o
HOSTLD scripts/kconfig/mconf
在此界面,你可以根据各选项的类型来进行切换操作。
有两类可切换选项:
- 布尔状态选项:这类选项只能关闭(
[ ])或作为内建组件开启([*])。 - 三态选项:这类选项可以关闭(
< >)、内建(<*>),或作为可加载模块(<M>)进行构建。
想要了解更多关于某个选项的信息,使用上/下箭头键导航至该选项,然后按 <TAB> 键,直至底部的 < Help > 选项被选中,然后按回车键进行选择。此时就会显示关于该配置选项的帮助信息。
在修改选项时请务必谨慎。
当你满意配置后,按 <TAB> 键直到底部的 < Save > 选项被选中。然后按回车键进行选择。然后再次按回车键(记住,此时不要更改文件名),就能将更新后的配置保存到 .config 文件中。
构建 Linux 内核¶
构建 Linux 内核实际上十分简单。然而,在开始构建之前,让我们为自定义内核构建添加一个标签。我将使用字符串 -pratham 作为标签,并利用 LOCALVERSION 变量来实施。你可以使用以下命令实现配置:
这一命令将 .config 文件中的 CONFIG_LOCALVERSION 配置选项设为我在结尾指定的字符串,即 -pratham。当然,你也不必非得使用我所用的名字哦 ;)
LOCALVERSION 选项可用于设置一个“本地”版本,它会被附加到通常的 x.y.z 版本方案之后,并在你运行 uname -r 命令时一并显示。
由于我正在构建的是 6.5.5 版本内核,而 LOCALVERSION 字符串被设为 -pratham,因此,对我来说,最后的版本名将会是 6.5.5-pratham。这么做的目的是确保我所构建的自定义内核不会与发行版所提供的内核产生冲突。
接下来,我们来真正地构建内核。可以用以下的命令完成此步骤:
这对大部分(99%)用户来说已经足够了。
其中的 -j 选项用于指定并行编译任务的数量。而 nproc 命令用于返回可用处理单位(包括线程)的数量。因此,-j$(nproc) 其实意味着“使用我拥有的 CPU 线程数相同数量的并行编译任务”。
2>&1 会将 STDOUT 和 STDIN 重定向到相同的文件描述符,并通过管道传输给 tee 命令,这会将输出存储在一个名为 log 的文件,并且在控制台打印出完全相同的文本。如果你在构建时遇到错误,并希望回顾日志来检查出了什么问题,这将会十分有用。遇到那种情况,你只需要简单执行 grep Error log 命令就能找到线索。
自定义 make 目标¶
在 Linux 内核的源文件夹中,make 命令有一些自定义的目标可供执行各种操作。这些主要作为开发者的参考。如果你的唯一目标是安装一个比你当前发行版更新的 Linux 内核,那么你完全可以跳过这部分内容 ;)
构建目标¶
作为一名开发者,你可能只想构建 Linux 内核,或者只想构建模块,或者只想构建设备树二进制(DTB)。在这种情况下,你可以指定一个构建目标,然后 make 命令只会构建指定的项目,而不会构建其他的。
以下是一些构建目标:
vmlinux:纯粹的 Linux 内核。modules:可加载模块。dtbs:设备树二进制文件(主要用于 ARM 和 RISC-V 架构)。all:构建所有被标记了星号*的项目(从make help的输出中可以查看)。
通常情况下,你并不需要指定构建目标,因为它们都已经在构建列表中。所列出的目标是在你只想要测试某一个构建目标,而不是其他目标时的情况。
依据你的 计算机架构,构建完成的 Linux 内核镜像(存放在 /boot 目录)的名称会有所不同。
对于 x86_64,Linux 内核的默认镜像名称是 bzImage。因此,如果你只需要构建引导所需的 Linux 内核,你可以像下面这样设定 bzImage 为目标:
“那么如何在我的架构上找到用来调用 make 的目标名称呢?”
有两种方法。要么你可以执行 make help 之后查找在 Architecture specific targets 下,第一个前面带有星号 * 的选项。
或者,如果你希望自动完成,你可以利用 image_name 目标得到镜像的完全路径(相对路径),选择性地添加 -s 标志来获得有用的输出。
以下是我拥有的三台电脑的输出,一台是 x86_64,另一台是 AArch64,还有一台是 riscv :
### x86_64
$ make -s image_name
arch/x86/boot/bzImage
### AArch64
$ make -s image_name
arch/arm64/boot/Image.gz
### RISC-V
$ make -s image_name
arch/riscv/boot/Image.gz
现在,要只构建 Linux 内核镜像,你可以这样进行:
清理目标¶
如果你需要清理构建产生的文件,你可以用以下的目标来实现你的需求:
clean:除了.config文件外,删除几乎所有其他内容。mrproper:执行了make clean的所有操作外,还会删除.config文件。distclean:除了执行make mrproper的所有操作外,还会清理任何补丁文件。
安装¶
一旦成功编译了 Linux 内核,接下来就是启动安装一些东西的时候了。“一些 东西?” 没错,我们至少构建了两种不同的东西,如果你使用的是 ARM 或 RISC-V 架构,那就有三种。我会在以下内容中详细解释。
🚧 虽然我将告诉你不同的安装方式,尤其是关于如何改变默认安装路径的方法,但**如果你不确定自己在做什么,那么我不建议你这么做!** 请慎重考虑,如果你决定走自定义的路线,那你需要自己负责后果。默认设置之所以存在,是因为它们有其特殊的原因 ;)
安装内核模块¶
Linux 内核有部分在系统启动时并非必需的。这些部分被构建为可加载模块,即在需要时才进行加载和卸载。
所以,首先需要安装这些模块。这可以通过 modules_install 目标完成。必须使用 sudo,因为模块会被安装在 /lib/modules/<kernel_release>-<localversion> 这个需要 root 权限的路径下。
这个过程不仅会安装内核模块,还会对其进行签名,所以可能需要一些时间。好消息是你可以通过之前提到的 -j$(nproc) 选项来并行执行安装任务,这样会快一些。;)
给开发者的提示: 你可以通过设定
INSTALL_MOD_PATH变量来指定一个不同的路径存放 Linux 模块,而不用默认的/lib/modules/<kernel_release>-<localversion>,具体如下:另一个给开发者的提示: 你可以使用
INSTALL_MOD_STRIP变量来决定是否需要剥离模块的调试符号。如果未设定该变量,调试符号**不会被剥离**。当设为1时,符号信息将会被使用--strip-debug选项剥离,随后该选项会传递给strip(或者在使用 Clang 的时候传递给llvm-strip)工具。
(可选)安装 Linux 内核头文件¶
如果你打算使用这个内核来支持树外模块,比如 ZFS 或英伟达 DKMS,或者打算尝试自行编写模块,你可能会需要 Linux 内核提供的头文件。
可以通过以下方式使用 headers_install 目标来安装 Linux 内核头文件:
应使用 sudo 命令,因为这些头文件会被安装到 /usr 目录。同时还会在 /usr 目录内创建子目录 include/linux,然后将头文件安装到 /usr/include/linux 内。
给开发者的提示: 通过设定
INSTALL_HDR_PATH变量,你可以修改 Linux 内核头文件的安装路径。
安装 DTB(只针对 ARM 和 RISC-V)¶
如果你使用的是 x86_64 架构,那么你可以跳过此步骤!
如果你针对 ARM 或者 RISC-V 构建了内核,那么在运行 make 的过程中,设备树的二进制文件可能已经被编译出来了。你可以通过在 arch/<machine_architecture>/boot/dts 目录查找 .dtb 文件来确认这一点。
这里提供了一个快速检查的技巧:
### 对于 AArch32
$ find arch/arm/boot/dts -name "*.dtb" -type f | head -n 1 > /dev/null && echo "DTBs for ARM32 were built"
### 对于 AArch64
$ find arch/arm64/boot/dts -name "*.dtb" -type f | head -n 1 > /dev/null && echo "DTBs for ARM64 were built"
### 对于 RISC-V
$ find arch/riscv/boot/dts -name "*.dtb" -type f | head -n 1 > /dev/null && echo "DTBs for RISC-V were built"
如果你看到出现 DTBs for <arch> were built 的消息,那么你可以开始安装 DTB。这可以通过 dtbs_install 目标来实现。
需要使用 sudo,因为它们会被安装在 /boot/dtb-<kernel_release>-<localversion> 中,而这个目录是由 root 所拥有的。
给开发者的提示: 就像安装模块一样,你可以使用
INSTALL_DTBS_PATH变量指定一个自定义的路径来安装设备树二进制文件。
安装 Linux 内核¶
最后,我们来安装 Linux 内核本身!这可以通过 install 目标来完成,就像这样:
在这里必须使用 sudo,因为 Linux 内核将被安装在 /boot 目录,而这个目录不允许普通用户写入。
💡 一般来讲,
install目标也会更新引导加载程序,但是如果它没有成功,那可能是不支持你使用的引导加载程序。如果你没有使用 GRUB 作为你的引导加载程序,请一定要阅读你引导加载程序的使用手册 ;)给开发者的提示: 并不奇怪,
INSTALL_PATH变量被用来设定 Linux 内核的安装位置,而非默认的/boot目录。
针对 Arch Linux 用户的说明¶
如果你尝试执行了 make install 命令,可能已经注意到产生了错误。错误如下:
要在 Arch Linux 上实际完成 Linux 内核的安装,我们需要手动复制 Linux 内核镜像文件。别担心,如果你使用的是 Arch Linux,手动操作应该是家常便饭了。( ͡° ͜ʖ ͡°)
可以使用以下命令完成这个步骤:
因为我编译的是 6.5.5 版本的内核,所以我将会执行下面这条命令,你可以根据你的实际情况进行适当调整:
虽然不是必须的,但最好复制一份名为 System.map 的文件。既然你已经在操作了,一并也复制了 .config 文件吧 ;)
sudo cp -vf System.map /boot/System.map-<kernel_release>-<localversion>
sudo cp -vf .config /boot/config-<kernel_release>-<localversion>
生成初始 RAM 磁盘¶
当你安装 Arch Linux 时,可能已经了解过 mkinitcpio 这个工具。现在,我们将使用它来创建初始的 RAM 磁盘。
首先,我们需要创建一个预设文件。向 /etc/mkinitcpio.d/linux-<localversion>.preset 文件中添加以下内容,根据实际需要来替换 <kernel_release> 和 <localversion>。
ALL_config="/etc/mkinitcpio.conf"
ALL_kver="/boot/vmlinuz-<kernel_release>-<localversion>"
PRESETS=('default' 'fallback')
default_image="/boot/initramfs-<kernel_release>-<localversion>.img"
fallback_options="-S autodetect"
配置完成后,执行下面的命令来生成初始 RAM 磁盘:
我自己的电脑上得到的输出如下,你的结果应该会类似!
$ sudo mkinitcpio -p linux-pratham
==> Building image from preset: /etc/mkinitcpio.d/linux-pratham.preset: 'default'
==> Using configuration file: '/etc/mkinitcpio.conf'
-> -k /boot/vmlinuz-6.5.5-pratham -c /etc/mkinitcpio.conf -g /boot/initramfs-6.5.5-pratham.img
==> Starting build: '6.5.5-pratham'
-> Running build hook: [base]
-> Running build hook: [udev]
-> Running build hook: [autodetect]
-> Running build hook: [modconf]
-> Running build hook: [kms]
-> Running build hook: [keyboard]
==> WARNING: Possibly missing firmware for module: 'xhci_pci'
-> Running build hook: [keymap]
-> Running build hook: [consolefont]
==> WARNING: consolefont: no font found in configuration
-> Running build hook: [block]
-> Running build hook: [filesystems]
-> Running build hook: [fsck]
==> Generating module dependencies
==> Creating zstd-compressed initcpio image: '/boot/initramfs-6.5.5-pratham.img'
==> Image generation successful
==> Building image from preset: /etc/mkinitcpio.d/linux-pratham.preset: 'fallback'
==> Using configuration file: '/etc/mkinitcpio.conf'
==> WARNING: No image or UKI specified. Skipping image 'fallback'
初始 RAM 磁盘已成功生成,现在我们可以进入下一步,更新引导加载器!
更新 GRUB¶
一旦所有必要的文件已成功复制到其对应的位置,接下来,我们将进行 GRUB 的更新。
使用以下命令对 GRUB 引导加载器进行更新:
💡 如果你使用的引导加载器不是 GRUB,请参看 Arch Wiki 中相关的引导加载器文档。
注意,更新 GRUB 并不会直接使新的内核版本设为默认启动选项。在引导时,请在启动菜单中手动选择新的内核版本。
你可以通过选择 Advanced options for Arch Linux 菜单,并在随后的菜单中选择 Arch Linux, with Linux <kernel_release>-<localversion> 来启用新版的 Linux 内核。
重启电脑¶
恭喜你!你已经完成了获取 Linux 内核源代码、进行配置、构建以及安装等所有步骤。现在只需要通过重启电脑并进入新构建和安装的 Linux 内核,就可以开始享受你的努力成果了。
启动时,请确保从引导加载器中选择正确的 Linux 内核版本。系统启动后,运行 uname -r 命令来确认你正在使用预期的 Linux 内核。
以下是我自己的电脑输出的内容:
是时候开始庆祝了! 🎉
卸载操作¶
🚧 提示:在删除当前正在使用的内核版本之前,你应该首先切换至较旧的内核版本。
可能你的 Linux 发行版所使用的 Linux 内核版本就是你手动编译的版本,或者你自行编译了新的内核并注意到应卸载旧的内核以节省空间,于是你开始想如何才能卸载。当然,虽然我们无法简单地运行 make uninstall 命令,但这并不代表没有其他的方法!
我们清楚各个文件的安装位置,因此删除它们相对简单。
### 删除内核模块
$ rm -rf /lib/modules/<kernel_release>-<localversion>
### 删除设备树二进制文件
$ rm -rf /boot/dtb-<kernel_release>-<localversion>
### 删除 Linux 内核本身
$ rm -vf /boot/{config,System,vmlinuz}-<kernel_release>-<localversion>
总结¶
这个过程不是一次简单的旅程,是吧?但是现在,我们终于抵达了终点。我们一起学习了手动编译 Linux 内核的全过程,包括安装依赖、获取和验证源码、解压源码、配置 Linux 内核、构建内核以及安装内核。
如果你喜欢这个详细的步骤指南,请给我留言反馈。如果在操作过程中遇到问题,也欢迎提出,让我知道!
(题图:MJ/853481c5-87e3-42aa-8ace-e9ddfa232f75)
via: https://itsfoss.com/compile-linux-kernel/
作者:Pratham Patel 选题:lujun9972 译者:ChatGPT 校对:wxy
Ended: 操作系统
计算机网络 ↵
TCP/IP 协议¶
IP地址分类¶
所谓的“分类的IP地址”就是将IP地址划分为若干个固定类,每一类地址都由两个固定长度的字段组成,其中第一个字段是**网络号,它标志主机(或路由器)所连接到的网络**。一个网络号在整个因特网范围内必须是唯一的。第二个字段是**主机号,它标志该主机(或路由器)**。一个主机号在它前面的网络号所指明的网络范围内必须是唯一的。由此可见,一个IP地址在整个因特网范围内是唯一的。
A类、B类、C类地址都是单播地址,它们的网络号字段分别是1,2,和3字节长,而在网络号字段的最前面有1~3位的类别位,其数值分为规定为0,10,110。它们的地址的主机号分为3个、2个和1个字节长。
D类地址(前4位是1110)用于多播,地址的网络号取值于224~239之间。而E类地址(前4位为1111)保留为以后用。
A类地址¶
A类地址的网络号字段占一个字节,只有7位可供使用,但**可指派的网络号是126个(即2的7次方-2)。减2的原因是:第一,IP地址中的全0是个保留地址,意思是“本网络”。第二,网络号为127(即01111111)保留作为本地软件环回测试本主机的进程之间的通信之用。A类地址的主机号占3个字节,因此**每一个A类网络中的最大主机数是2的24次方-2。减2的原因是:全0的主机号字段表示该IP地址是“本主机”所连接到的单个网络地址,而全1表示“所有的”,因此全1的主机号字段表示该网络上的所有主机。(主机号:全0代表网络地址,全1代表广播地址)。
A类地址默认子网掩码:255.0.0.0或 0xFF000000
B类地址¶
B类地址的网络号字段有2个字节,当前面两位(10)已经固定了,只剩下14位可以进行分配。因为网络号字段后面的14位无论怎么取值也不可能出现使整个2字节的网络号字段成为全0或全1,因此这里不存在网络总数减2的问题。但实际上B类网络地址128.0.0.0是不指派的,而可以指派的B类最小网络地址是128.1.0.0。因此B类地址可指派的网络数为2的14次方-1。B类地址的每一个网络上的最大主机数是2的16次方-2(减去全0和全1的主机号)。
B类地址默认子网掩码:255.255.0.0或0xFFFF0000
C类地址¶
C类地址有3个字节的网络号字段,最前面的3位是(110),还有21位可以进行分配。C类网络地址的192.0.0.0也是不指派的,可以指派的C类最小网络地址是192.0.1.0.因此,C类地址可指派的网络总数是2的21次方-1。每一个C类地址的最大主机数是2的8次方-2。
C类地址默认子网掩码:子网掩码:255.255.255.0或 0xFFFFFF00
总结: 所有类型的主机号都是-2。网络号,只有A类是-2,其余的都是-1。
局域网地址¶
局域网使用的网段(私网地址段)有三大段:
- 10.0.0.0~zhi10.255.255.255(A类)
- 172.16.0.0~172.31.255.255(B类)
- 192.168.0.0~192.168.255.255(C类)
子网掩码(subnet mask)¶
子网掩码(subnet mask)又叫网络掩码、地址掩码、子网络遮罩,它是一种用来指明一个IP地址的哪些位标识的是主机所在的子网,以及哪些位标识的是主机的位掩码。
子网掩码作用:
-
通过子网掩码,就可以判断两个IP在不在一个局域网内部。
把子网掩码和IP地址进行逐位的“与”运算,就立即得出网络地址。若得出两个IP的网络地址一样,说明他们在一个局域网内部
-
子网掩码可以看出有多少位是网络号,有多少位是主机号:
比如255.255.255.0 二进制是:11111111 11111111 11111111 00000000, 其网络号24位,即全是1, 主机号8位,即全是0
CIDR¶
CIDR还使用“斜线记法”或称为CIDR记法,即在IP地址后面加上斜线“/”,然后协商网络前缀所占的位数。
129.168.1.1/24中的24就是告诉我们网络号是24位,也就相当于告诉我们了子网掩码是:11111111 11111111 11111111 00000000即:255.255.255.0
172.16.10.33/27中的/27也就是说子网掩码是255.255.255.224 即27个全1 ,11111111 11111111 11111111 11100000
tcpdump¶
tcpdump命名选项:
- -i 指定监听的网络接口,any表明所有接口
- -nn IP和端口均以数字形式显示
- -c 在收到指定的数量的分组后,tcpdump停止,如果没有这个参数,tcpdump会持续不断的监听直到用户输入 [ctrl]-c 为止
- -e 输出数据链路层的头部信息(显示MAC地址相关信息)。
- -t 在输出的每一行不打印时间戳
-
-q 只输出较少的协议信息(仅输出协议名称,如TCP;而不输出封包标记信息,如F、P等标记)
-
-w FILE直接将分组写入文件中,而不是到stdout
- -r FILE从后面接的文件将数据包数据读出来。那个「文件」是已经存在的文件,并且这个「文件」是由 -w 所制作出来的
- -s 设置tcpdump的数据包抓取长度为len,如果不设置默认将会是65535字节。对于要抓取的数据包较大时,长度设置不够可能会产生包截断,若出现包截断,输出行中会出现"[|proto]“的标志(proto实际会显示为协议名)。但是抓取len越长,包的处理时间越长,并且会减少tcpdump可缓存的数据包的数量,从而会导致数据包的丢失,所以在能抓取我们想要的包的前提下,抓取长度越小越好(-s 0 使用默认长度65535)。
- -D 列出可用于抓包的接口。将会列出接口的数值编号和接口名,它们都可以用于”-i"后
- -L 列出网络接口的已知数据链路。
-
-F 从文件中读取抓包的过滤表达式。若使用该选项,则命令行中给定的其他表达式都将失效。
-
-A 数据包的内容以 ASCII 显示,通常用来捉取WWW的网页数据包资料
- -X 数据包内容以十六进制 (hex)和ASCII显示,对于监听数据包内容很有用
- -XX 比-X输出更详细
- -S Print absolute, rather than relative, TCP sequence numbers
sudo tcpdump -D
sudo tcpdump -L
sudo tcpdump port 8080 -e -XX -S
sudo tcpdump src 10.5.2.3 and dst port 3389
- option 可选参数:将在后边一一解释。
- proto 类过滤器:根据协议进行过滤,可识别的关键词有: tcp, udp, icmp, ip, ip6, arp, rarp,ether,wlan, fddi, tr, decnet
- type 类过滤器:可识别的关键词有:host, net, port, portrange,这些词后边需要再接参数。
- direction 类过滤器:根据数据流向进行过滤,可识别的关键字有:src, dst,同时你可以使用逻辑运算符进行组合,比如 src or dst
示例:
- 只抓取syn包
tcpdump -i eth0 "tcp[13] & 2 != 0" // 13代表第13个字节(字节数从0开始),2是syc标志位置1时候的值
tcpdump -i eth0 "tcp[tcpflags] & tcp-syn != 0" // tcpflags是13的别名,tcp-syn是2的别名
tcpdump -i eth0 "tcp[tcpflags] & 2 != 0"
tcpdump -i eth0 "tcp[13] & tcp-syn != 0"
- 同时抓取syn和ack包
tcpdump -i eth0 'tcp[13] == 2 or tcp[13] == 16'
tcpdump -i eth0 'tcp[tcpflags] == tcp-syn or tcp[tcpflags] == tcp-ack'
tcpdump -i eth0 "tcp[tcpflags] & (tcp-syn|tcp-ack) != 0"
tcpdump -i eth0 'tcp[13] = 18' // 注意是等号。18(syn+ack) = 2(syn) + 16(ack)
tcpdump -i eth0 'tcp[tcpflags] = 18'
- 抓取http GET 请求的包
tcp[12:1]&0xf0表示为第13个字节与0xf0(b11110000)进行与运算。第13个字节的前4位是tcp首部长度,且单位值是4字节(假如该4位的值是5,则表明tcp部首部长度为5 * 4字节)。tcp[12:1] & 0xf0 计算后的值相当于tcp部首部的4位的值乘以2^4 = 16,如果再除以4,即右移2位就是tcp部首部长度。所以(tcp[12:1] & 0xf0)) >> 2表示为tcp首部长度。
tcp[((tcp[12:1] & 0xf0) >> 2):4]表示数据部分前4个字节。0x47455420是GET的ASCII码。0x20是空格。两者部分相等就能抓取GET请求的包
TCP/IP 的四层协议¶
OSI七层协议模型主要是:
应用层(Application)、表示层(Presentation)、会话层(Session)、传输层(Transport)、网络层(Network)、数据链路层(Data Link)、物理层(Physical)。
数据封装¶
当应用程序用TCP传送数据时,数据被送入协议栈中,然后逐个通过每一层直到被当作一串比特流送入网络。其中每一层对收到的数据都要增加一些首部信息(有时还要增加尾部信息),该过程如下图所示。TCP传给IP的数据单元称作TCP报文段或简称为TCP段(TCP segment)。IP传给网络接口层的数据单元称作IP数据报(IP datagram)。通过以太网传输的比特流称作帧(Frame)。
图来源: http://docs.52im.net/extend/docs/book/tcpip/vol1/1/
分用¶
当目的主机收到一个以太网数据帧时,数据就开始从协议栈中由底向上升,同时去掉各层协议加上的报文首部。每层协议盒都要去检查报文首部中的协议标识,以确定接收数据的上层协议。这个过程称作分用(Demultiplexing),下图显示了该过程是如何发生的。
链路层¶
在TCP/IP协议族中,链路层主要有三个目的:(1)为IP模块发送和接收IP数据报;(2)为ARP模块发送ARP请求和接收ARP应答;(3)为RARP发送RARP请求和接收RARP应答。TCP/IP支持多种不同的链路层协议,这取决于网络所使用的硬件,如以太网、令牌环网、FDDI(光纤分布式数据接口)及RS-232串行线路等
以太帧¶
48 bit(6字节)的目的地址和源地址
802.3标准定义的帧和以太网的帧都有最小长度要求。802.3规定数据部分必须至少为38字节,而对于以太网,则要求最少要有46字节。为了保证这一点,必须在不足的空间插入填充(pad)字节。在开始观察线路上的分组时将遇到这种最小长度的情况。
CRC字段用于帧内后续字节差错的循环冗余码检验(检验和)
IP:网际协议¶
IP是TCP/IP协议族中最为核心的协议。所有的TCP、UDP、ICMP及IGMP数据都以IP数据报格式传输。IP提供不可靠、无连接的数据报传送服务。
不可靠(unreliable) 的意思是它不能保证IP数据报能成功地到达目的地。IP仅提供最好的传输服务。如果发生某种错误时,如某个路由器暂时用完了缓冲区,IP有一个简单的错误处理算法:丢弃该数据报,然后发送ICMP消息报给信源端。任何要求的可靠性必须由上层来提供(如TCP)。
无连接(connectionless) 这个术语的意思是IP并不维护任何关于后续数据报的状态信息。每个数据报的处理是相互独立的。这也说明,IP数据报可以不按发送顺序接收。如果一信源向相同的信宿发送两个连续的数据报(先是A,然后是B),每个数据报都是独立地进行路由选择,可能选择不同的路线,因此B可能在A到达之前先到达。
IP首部¶
普通的IP首部长为20个字节,除非含有选项字段
-
版本号(Version):长度4比特。标识目前采用的IP协议的版本号。一般的值为0100(IPv4),0110(IPv6)
-
IP包头长度(Header Length):简写IHL。长度4比特。这个字段的作用是为了描述IP包头的长度,因为在IP包头中有变长的可选部分。该部分占4个bit位,单位为32bit(4个字节),即本区域值= IP头部长度(单位为bit)/(8*4),因此,一个IP包头的长度最长为“1111”,即15*4=60个字节。IP包头最小长度为20字节。
-
服务类型(Type of Service):简写TOC。长度8比特。8位按位被如下定义: > PPP DTRC0
PPP:定义包的优先级,占用3bit, 取值越大数据越重要 - 000 普通 (Routine) - 001 优先的 (Priority) - 010 立即的发送 (Immediate) - 011 闪电式的 (Flash) - 100 比闪电还闪电式的 (Flash Override) - 101 CRI/TIC/ECP(找不到这个词的翻译) - 110 网间控制 (Internetwork Control) - 111 网络控制 (Network Control)
D 时延: 0:普通 1:延迟尽量小
T 吞吐量: 0:普通 1:流量尽量大
R 可靠性: 0:普通 1:可靠性尽量大
M 传输成本: 0:普通 1:成本尽量小
0 最后一位被保留,恒定为0
-
IP包总长(Total Length):长度16比特。 以字节为单位计算的IP包的长度 (包括头部和数据),所以**IP包最大长度65535字节**。尽管可以传送一个长达65535字节的IP数据报,但是大多数的链路层都会对它进行分片。
-
标识符(Identifier):唯一地标识主机发送的每一份数据报。通常每发送一份报文它的值就会加1。长度16比特。该字段和Flags和Fragment Offest字段联合使用,对较大的上层数据包进行分段(fragment)操作。路由器将一个包拆分后,所有拆分开的小包被标记相同的值,以便目的端设备能够区分哪个包属于被拆分开的包的一部分
-
标记(Flags):长度3比特。该字段第一位不使用。第二位是DF(Don't Fragment)位,DF位设为1时表明路由器不能对该上层数据包分段。如果一个上层数据包无法在不分段的情况下进行转发,则路由器会丢弃该上层数据包并返回一个错误信息。第三位是MF(More Fragments)位,当路由器对一个上层数据包分段,则路由器会在除了最后一个分段的IP包的包头中将MF位设为1。
-
片偏移(Fragment Offset):长度13比特。表示该IP包在该组分片包中位置,接收端靠此来组装还原IP包。
-
生存时间(TTL):长度8比特。当IP包进行传送时,先会对该字段赋予某个特定的值(通常为32或64)。当IP包经过每一个沿途的路由器的时候,每个沿途的路由器会将IP包的TTL值减少1。如果TTL减少为0,则该IP包会被丢弃。这个字段可以防止由于路由环路而导致IP包在网络中不停被转发。
-
协议(Protocol): 长度8比特。标识了上层所使用的协议。以下是比较常用的协议号: 协议号 | 协议 --- | --- 1 | ICMP 2 | IGMP 6 | TCP 17 | UDP 88 | IGRP 89 | OSPF
-
头部校验(Header Checksum):校验和。长度16位。用来做IP头部的正确性检测,但不包含数据部分。 因为每个路由器要改变TTL的值, 所以路由器会为每个通过的数据包重新计算这个值。
为了计算一份数据报的IP检验和,首先把检验和字段置为0。然后,对首部中每个16 bit进行二进制反码求和(整个首部看成是由一串16 bit的字组成),结果存在检验和字段中。当收到一份IP数据报后,同样对首部中每个16 bit进行二进制反码的求和。由于接收方在计算过程中包含了发送方存在首部中的检验和,因此,如果首部在传输过程中没有发生任何差错,那么接收方计算的结果应该为全1。如果结果不是全1(即检验和错误),那么IP就丢弃收到的数据报。但是不生成差错报文,由上层去发现丢失的数据报并进行重传。
-
起源和目标地址(Source and Destination Addresses): 这两个地段都是32比特。标识了这个IP包的起源和目标地址。要注意除非使用NAT,否则整个传输的过程中,这两个地址不会改变。
-
可选项(Options): 这是一个可变长的字段。该字段属于可选项,主要用于测试
如何计算UDP/TCP检验和checksum?¶
一个UDP的检验和所需要用到的所有信息,包括三个部分: 1. UDP伪首部 2. UDP首部 3. UDP的数据部分
伪首部包含IP首部一些字段。其目的是让UDP两次检查数据是否已经正确到达目的地。
计算检验和(checksum)的过程很关键,主要分为以下几个步骤: 1. 把伪首部添加到UDP上; 2. 计算初始时是需要将检验和字段添零的; 3. 把所有位划分为16位(2字节)的字
- 把所有16位的字相加,如果遇到进位,则将高于16字节的进位部分的值加到最低位上,举例,0xBB5E+0xFCED=0x1 B84B,则将1放到最低位,得到结果是0xB84C
- 将所有字相加得到的结果应该为一个16位的数,将该数取反则可以得到检验和checksum。
unsigned short check_sum(unsigned short *a, int len)
{
unsigned int sum = 0;
while (len > 1) {
sum += *a++;
len -= 2;
}
if (len) {
sum += *( unsigned char *)a;
}
while (sum >> 16) {
sum = (sum >> 16) + (sum & 0xffff);
}
return ( unsigned short)(~sum);
}
TCP:传输控制协议¶
TCP提供一种**面向连接的、可靠的字节流**服务。面向连接意味着两个使用TCP的应用(通常是一个客户和一个服务器)在彼此交换数据之前必须先建立一个TCP连接。TCP协议保证数据传输可靠性的方式主要有:
-
字节流
应用数据被分割成TCP认为最适合发送的数据块。这和UDP完全不同,应用程序产生的数据报长度将保持不变。由TCP传递给IP的信息单位称为报文段或段(segment)
-
校验和
TCP将保持它首部和数据的检验和。这是一个端到端的检验和,目的是检测数据在传输过程中的任何变化。如果收到段的检验和有差错,TCP将丢弃这个报文段和不确认收到此报文段(希望发端超时并重发)。 - 序列号
既然TCP报文段作为IP数据报来传输,而IP数据报的到达可能会失序,因此TCP报文段的到达也可能会失序。如果必要,TCP将对收到的数据进行重新排序,将收到的数据以正确的顺序交给应用层。 - 确认应答
当TCP收到发自TCP连接另一端的数据,它将发送一个确认。这个确认不是立即发送,通常将推迟几分之一秒。 - 超时重传 当TCP发出一个段后,它启动一个定时器,等待目的端确认收到这个报文段。如果不能及时收到一个确认,将重发这个报文段。
-
连接管理
-
流量控制 TCP还能提供流量控制。TCP连接的每一方都有固定大小的缓冲空间。TCP的接收端只允许另一端发送接收端缓冲区所能接纳的数据。这将防止较快主机致使较慢主机的缓冲区溢出。
-
拥塞控制
TCP首部¶
TCP首部通常是20个字节。
- 16位源端口号:告知主机该报文段来自哪里(源端口)
- 16位目的端口: 告知传给哪个上层协议或应用程序的端口。进行tcp通信时,客户端通常使用系统自动选择的临时端口号,而服务器端口固定。
-
32位序号: TCP 连接中,为传送的字节流(数据)中的每一个字节按顺序编号。也就是说,在一次 TCP 连接建立的开始,到 TCP 连接的断开,你要传输的所有数据的每一个字节都要编号。这个序号称为字节序号。
当新连接建立的时候,第一个字节数据的序号称为 ISN(Initial Sequence Number),即初始序号。ISN 一开始并不一定就是 1。该主机要发送数据的第一个字节序号为这个ISN加1,因为SYN标志消耗了一个序号。
此处的32位序号是报文段序号。如果一个 TCP 报文段的序号为 301,它携带了 100 字节的数据,就表示这 100 个字节的数据的字节序号范围是 [301, 400],该报文段携带的第一个字节序号是 301,最后一个字节序号是 400.
-
32位确认序号:确认序号包含发送确认的一端所期望收到的下一个序号。因此,确认序号应当是上次已成功收到数据字节序号加1。只有ACK标志为1时确认序号字段才有效。发送ACK无需任何代价,因为32 bit的确认序号字段和ACK标志一样,总是TCP首部的一部分。一旦一个连接建立起来,这个字段总是被设置,ACK标志也总是被设置为1。
在 TCP 协议中,一般采用累积确认的方式,即每传送多个连续 TCP 段,可以只对最后一个 TCP 段进行确认。
-
4位头部长度:标识该tcp头部有多少个32bit字(4字节)因为4位最大能表示15,所以tcp头部最长是60字节。
-
6位标志位:志位有如下几项
- URG标志,表示紧急指针是否有效
- ACK标志,表示确认号是否有效。称携带ACK标志的tcp报文段位确认报文段
- PSH标志,提示接收端应用程序应该立即从tcp接受缓冲区中读走数据,为接受后续数据腾出空间(如果应用程序不将接收的数据读走,它们就会一直停留在tcp缓冲区中)
- RST标志,表示要求对方重新建立连接。携带RST标志的tcp报文段为复位报文段。
- SYN标志,表示请求建立一个连接。携带SYN标志的tcp报文段为同步报文段。
- FIN标志,表示通知对方本端要关闭连接了。携带FIN标志的tcp报文段为结束报文段。
-
16位窗口大小:tcp流量控制的一个手段。这里说的窗口,指的是接收通告窗口。它告诉对方本端的tcp接收缓冲区还能容纳多少字节的数据,这样对方就可以控制发送数据的速度。
-
16位校验和:由发送端填充,接收端对tcp报文段执行CRC算法以校验tcp报文段在传输过程中是否损坏。注意,这个校验不仅包括tcp头部,也包括数据部分。这也是tcp可靠传输的一个重要保障。
-
16位紧急指针:是一个正的偏移量。它和序号字段的值相加表示最后一个紧急数据的下一个字节的序号。因此,确切的说,这个字段是紧急指针相对当前序列号的偏移,称为紧急偏移。tcp的紧急指针是发送端向接收端发送紧急数据的方法。
-
16位选项:TCP头部的最后一个选项字段是可变长的可选信息。这部分最多包含40字节,因为TCP头部最长是60字节(其中还包含前面讨论的20字节的固定部分) 最常见的可选字段是最长报文大小,又称为MSS(Maximum Segment Size)。每个连接方通常都在通信的第一个报文段(为建立连接而设置SYN标志的那个段)中指明这个选项。它指明本端所能接收的最大长度的报文段。
TCP连接建立与终止¶
三次握手(three-way handshake)¶
- (B) → [SYN] → (A)。请求端(通常称为客户)发送一个SYN段指明客户打算连接的服务器的端口,以及初始序号(ISN,在这个例子中为1415531521)。这个SYN段为报文段1。 一个 SYN包就是仅SYN标记设为1的TCP包。
- (B) ← [SYN/ACK] ←(A)。服务器发回包含服务器的初始序号的SYN报文段(报文段2)作为应答。同时,将确认序号设置为客户的ISN加1以对客户的SYN报文段进行确认。一个SYN将占用一个序号。
- (B) → [ACK] → (A)。客户必须将确认序号设置为服务器的ISN加1以对服务器的SYN报文段进行确认(报文段3)。ACK包就是仅ACK 标记设为1的TCP包.
发送第一个SYN的一端将执行主动打开(active open)。接收这个SYN并发回下一个SYN的另一端执行被动打开(passive open)。当三此握手完成、连接建立以后,TCP连接的每个包都会设置ACK位。
四次握手(Four-way Handshake)¶
一个TCP连接是全双工(即数据在两个方向上能同时传递),因此每个方向必须单独地进行关闭。收到一个FIN只意味着在这一方向上没有数据流动。一个TCP连接在收到一个FIN后仍能发送数据。而这对利用半关闭的应用来说是可能的。
- (B) → [ACK/FIN] → (A) 首先进行关闭的一方(即发送第一个FIN)将执行主动关闭,而另一方(收到这个FIN)执行被动关闭。
- (B) ← [ACK] ← (A) 当服务器收到这个FIN,它发回一个ACK,确认序号为收到的序号加1
- (B) ← [ACK/FIN] ← (A) 接着这个服务器程序就关闭它的连接,导致它的TCP端发送一个FIN
- (B) → [ACK] → (A) 客户必须发回一个确认,并将确认序号设置为收到序号加1
注意: ACK/FIN 包(ACK 和FIN 标记设为1)通常被认为是FIN(终结)包。因为由于连接还没有关闭, FIN包总是打上ACK标记. 没有ACK标记而仅有FIN标记的包不是合法的包,并且通常被认为是恶意的。
连接复位Resetting a connection¶
四次握手不是关闭TCP连接的唯一方法. 有时,如果主机需要尽快关闭连接(或连接超时,端口或主机不可达),RST (Reset)包将被发送. 注意在,由于RST包不是TCP连接中的必须部分, 可以只发送RST包(即不带ACK标记). 但在正常的TCP连接中RST包可以带ACK确认标记。
注意: RST包是可以不要收到方确认的
08:35:33.802391 IP homestead.20854 > homestead.9011: Flags [S], seq 630677314, win 43690, options [mss 65495,sackOK,TS val 6205701 ecr 0,nop,wscale 7], length 0
08:35:33.802415 IP homestead.9011 > homestead.20854: Flags [R.], seq 0, ack 630677315, win 0, length 0
无效的TCP标记Invalid TCP Flags¶
到目前为止,你已经看到了 SYN, ACK, FIN, 和RST 标记. 另外,还有PSH (Push) 和URG (Urgent)标记. 最常见的非法组合是SYN/FIN 包. 注意:由于 SYN包是用来初始化连接的, 它不可能和 FIN和RST标记一起出现. 这也是一个恶意攻击. 由于现在大多数防火墙已知 SYN/FIN 包, 别的一些组合,例如SYN/FIN/PSH, SYN/FIN/RST, SYN/FIN/RST/PSH。很明显,当网络中出现这种包时,很你的网络肯定受到攻击了。 别的已知的非法包有FIN (无ACK标记)和"NULL"包。如同早先讨论的,由于ACK/FIN包的出现是为了关闭一个TCP连接,那么正常的FIN包总是带有 ACK 标记。"NULL"包就是没有任何TCP标记的包(URG,ACK,PSH,RST,SYN,FIN都为0)。 到目前为止,正常的网络活动下,TCP协议栈不可能产生带有上面提到的任何一种标记组合的TCP包。当你发现这些不正常的包时,肯定有人对你的网络不怀好意。
最大传输单元 - MTU¶
MTU(Maximum Transmit Unit),最大传输单元,即物理接口(数据链路层)提供给其上层(通常是IP层)最大一次传输数据的大小;以普遍使用的以太网接口为例,缺省MTU=1500 Byte,这是以太网接口对IP层的约束,如果IP层有<=1500 byte 需要发送,只需要一个IP包就可以完成发送任务;如果IP层有> 1500 byte。数据需要发送,需要分片才能完成发送,这些分片有一个共同点,即IP Header ID相同。
各种网络设备的MTU列表
| 网络 | MTU |
|---|---|
| 超通道 | 65535 |
| 16Mb/s令牌环(IBM) | 17914 |
| 4MB/s令牌环(IEEE802.5) | 4464 |
| FDDI | 4352 |
| 以太网 | 1500 |
| IEEE802.3/2 | 1492 |
| x.25 | 576 |
)
最大报文段长度 - MSS¶
MSS(Maximum Segment Size),TCP提交给IP层最大分段大小,不包含TCP Header和 TCP Option,只包含TCP Payload ,MSS是TCP用来限制应用层层最大的发送字节数。
当TCP发送一个SYN时,或者是因为一个本地应用进程想发起一个连接,或者是因为另一端的主机收到了一个连接请求,它能将MSS值设置为外出接口上的MTU长度减去固定的IP首部和TCP首部长度。对于一个以太网,MSS值可达1460字节。使用IEEE 802.3的封装,它的MSS可达1452字节。
以太网的MSS1460 = 链路层(即网络设备)的协议的MTU(1500) - IP首部长度(20) - TCP首部长度(20)
对于不经过物理网卡之间的通讯(比如内部两个进程间进行连接),其MTU能达到65535。减去IP和TCP首部长度长度,TCP最大报文MSS = 65535 - 20字节IP头 - 20字节TCP头 = 65495。
MSS分析示例¶
从上图packet 1和packet 2看出来客户端和服务端滑动窗口大小都是43690,MSS都是65495。从packet 6 和packet 7中可以看到实际传输内容大小并没有达到65495。而是21888。并且大概每2个TCP报文会设置P标志位(6也传输了数据,但仅设置了ACK标志,并没有设置Push标志)
packet 4 是客户端请求服务端peacan.txt文件,其Seq: 4180142910, Len: 86。packet 5是服务器确认应该4的请求,Seq: 2273561287, Ack: 4180142996, Len: 0,其中Ack 等于4中Seq + Len。
接下来packet 6,7,10,11,12,13,14,15为服务端响应信息。这个包由于内容过大进行拆包了,Ack都是4180142996,这是重组的一个完整包体的。最后看到的TCP segment of a reassembled PDU,字面意思是要重组的协议数据单元(PDU:Protocol Data Unit)的TCP段。比如由多个数据包组成的HTTP协议的应答包。这里的分段是指:上层协议HTTP的应答由多个分段组成,每个分段都是TCP协议的。TCP本身没有分段的概念,它的sequence number和acknowledge number 是使TCP是基于流的协议的支撑,TCP segment of a reassembled PDU的出现是因为Wireshark分析了其上层的HTTP协议而给出的摘要
TCP选项说明:
- TSval : 发送端时间, TS是时间戳缩写
- TSecr: 接收端时间
- sackOK:启用了 SACK 算法
- nop: 占位符,没任何选项
- wscale :窗口因子 7
报文最大生存时间 - MSL¶
数据包在网络中是有生存时间的,超过这个时间还未到达目标主机就会被丢弃,并通知源主机。这称为报文最大生存时间(MSL,Maximum Segment Lifetime)。
RFC 793 [Postel 1981c]指出MSL为2分钟。然而,实现中的常用值是30秒,1分钟,或2分钟。
TCP的状态变迁¶
| 状态码 | 说明 |
|---|---|
| CLOSED | 初始(无连接)状态。 |
| LISTEN | 侦听状态,等待远程机器的连接请求。 |
| SYN_SEND | 在TCP三次握手期间,主动连接端发送了SYN包后,进入SYN_SEND状态,等待对方的ACK包。 |
| SYN_RECV | 在TCP三次握手期间,主动连接端收到SYN包后,进入SYN_RECV状态。 |
| ESTABLISHED | 完成TCP三次握手后,主动连接端进入ESTABLISHED状态。此时,TCP连接已经建立,可以进行通信。 |
| FIN_WAIT_1 | 在TCP四次挥手时,主动关闭端发送FIN包后,进入FIN_WAIT_1状态。 |
| FIN_WAIT_2 | 在TCP四次挥手时,主动关闭端收到ACK包后,进入FIN_WAIT_2状态。 |
| TIME_WAIT | 在TCP四次挥手时,主动关闭端发送了ACK包之后,进入TIME_WAIT状态,等待最多MSL时间,让被动关闭端收到ACK包。 |
| CLOSING | 在TCP四次挥手期间,主动关闭端发送了FIN包后,没有收到对应的ACK包,却收到对方的FIN包,此时,进入CLOSING状态。常见于同时关闭连接情况 |
| CLOSE_WAIT | 在TCP四次挥手期间,被动关闭端收到FIN包后,进入CLOSE_WAIT状态。 |
| LAST_ACK | 在TCP四次挥手时,被动关闭端发送FIN包后,进入LAST_ACK状态,等待对方的ACK包。 |
客户端最后一次发送 ACK包后进入 TIME_WAIT 状态,而不是直接进入 CLOSED 状态关闭连接,这是为什么呢?¶
客户端最后一次向服务器回传ACK包时,有可能会因为网络问题导致服务器收不到,服务器会再次发送 FIN 包,如果这时客户端完全关闭了连接,那么服务器无论如何也收不到ACK包了,所以客户端需要等待片刻、确认对方收到ACK包后才能进入CLOSED状态。那么,要等待多久呢?
TIME_WAIT 要等待 2MSL 才会进入 CLOSED 状态。ACK 包到达服务器需要 MSL 时间,服务器重传 FIN 包也需要 MSL 时间,2MSL 是数据包往返的最大时间,如果 2MSL 后还未收到服务器重传的 FIN 包,就说明服务器已经收到了 ACK 包。
TCP定时器与超时¶
两个时间概念:
- RTT(Round Trip Time):一个连接的往返时间,即数据发送时刻到接收到确认的时刻的差值;
- RTO(Retransmission Time Out):重传超时时间,即从数据发送时刻算起,超过这个时间便执行重传。
RTT和RTO 的关系是:由于网络波动的不确定性,每个RTT都是动态变化的,所以RTO也应随着RTT动态变化。
相关的内核参数可以通过man 7 tcp了解详细内容。
Connection-Establishment Timer¶
在TCP三次握手创建一个连接时,以下两种情况下会发生超时:
-
client发送SYN后,进入SYN_SENT状态,等待server的SYN+ACK。
-
server收到连接创建的SYN,回应SYN+ACK后,进入SYN_RECD状态,等待client的ACK。
在Linux实现中,并不是依靠超时总时间来判断是否终止连接。而是依赖重传次数。
第一种情况下超时重传次数是依赖内核配置tcp_syn_retries, 在内核4.4.0-92-generic中默认是6次。即超时127s(在[RFC 6298]中,推荐初始超时重传时间为1秒。超时等待是按照2的幂来backoff的,127 = 2^0 + 2^1 + ... + 26,注意第6次发送SYN之后还会等待26秒)会终止连接。
第二种情况超时重传次数依赖的内核配置是tcp_synack_retries
第一种超时重试实验:
iptables -A INPUT --protocol tcp --dport 5000 --syn -j DROP // 配置 iptables 来丢弃指定端口的 SYN 报文
tcpdump -i lo -Ss0 -n src 127.0.0.1 and dst 127.0.0.1 and port 5000 // 打开 tcpdump 观察到达指定端口的报文
date '+ %F %T'; telnet 127.0.0.1 5000; date '+ %F %T'; // 连接指定端口
Retransmission Timer¶
当Tcp连接建立之后,每次发送TCP segment,等待ACK确认。如果在指定时间内,没有得到ACK,就会重传,一直重传到放弃为止。Linux中也有相关变量来设置这里的重传次数的:tcp_retries1,tcp_retries2
Delayed ACK Timer¶
当一方接受到TCP segment,需要回应ACK。但是不需要 立即 发送,而是等上一段时间,看看是否有其他数据可以 捎带 一起发送。这段时间便是 Delayed ACK Timer ,一般为200ms。这种现象也称为数据捎带ACK。具体参见TCP/IP详解第19章-经受时延的确认
Persist Timer¶
如果某一时刻,一方发现自己的 socket read buffer 满了,无法接受更多的TCP data,此时就是在接下来的发送包中指定通告窗口的大小为0,这样对方就不能接着发送TCP data了。如果socket read buffer有了空间,可以重设通告窗口的大小在接下来的 TCP segment 中告知对方。可是万一这个 TCP segment 不附带任何data,所以即使这个segment丢失也不会知晓(ACKs are not acknowledged, only data is acknowledged)。对方没有接受到,便不知通告窗口的大小发生了变化,也不会发送TCP data。这样双方便会一直僵持下去。
TCP协议采用这个机制避免这种问题:对方即使知道当前不能发送TCP data,当有data发送时,过一段时间后,也应该尝试发送一个字节。这段时间便是 Persist Timer 。
Keepalive Timer¶
TCP socket 的 SO_KEEPALIVE option,主要适用于这种场景:连接的双方一般情况下没有数据要发送,仅仅就想尝试确认对方是否依然在线。
具体实现方法:TCP每隔一段时间(tcp_keepalive_intvl)会发送一个特殊的Probe Segment ,强制对方回应,如果没有在指定的时间内回应,便会重传,一直到重传次数达到tcp_keepalive_probes便认为对方已经crash了。
-
tcp_keepalive_intvl
探测消息发送的频率,默认值是75秒。若对方在tcp_keepalive_probes * tcp_keepalive_intvl,约11分钟没有任何响应,则认为对方已经crash
-
tcp_keepalive_probes
TCP发送keepalive探测以确定该连接已经断开的次数。默认值是9
-
tcp_keepalive_time
当keepalive打开的情况下,TCP发送keepalive消息的频率。在TCP开始发送保持活动的探测之前,连接需要空闲的秒数。
保活时间net.ipv4.tcp_keepalive_time、保活时间间隔net.ipv4.tcp_keepalive_intvl、保活探测次数net.ipv4.tcp_keepalive_probes,默认值分别是 7200 秒(2 小时)、75 秒和 9 次探测。如果使用 TCP 自身的 keep-Alive 机制,在 Linux 系统中,最少需要经过 2 小时 + 9*75 秒后断开。
Linux 4.1内核版本之前除了tcp_tw_reuse以外,还有一个参数tcp_tw_recycle,这个参数就是强制回收time_wait状态的连接,它会导致NAT环境丢包。
FIN_WAIT_2 Timer¶
当主动关闭方想关闭TCP connection,发送FIN并且得到相应ACK,从FIN_WAIT_1状态进入FIN_WAIT_2状态,此时不能发送任何data了,只等待对方发送FIN。可以万一对方一直不发送FIN呢?这样连接就一直处于FIN_WAIT_2状态,也是很经典的一个DoS。因此需要一个Timer,超过这个时间,就放弃这个TCP connection了。
-
tcp_fin_timeout
默认值是60秒。这个参数决定了主动关闭方保持在FIN-WAIT-2状态的时间。
TIME_WAIT Timer¶
TIME_WAIT Timer存在的原因和必要性,主要是两个方面:
- 主动关闭方发送了一个ACK给对方,假如这个ACK发送失败,并导致对方重发FIN信息,那么这时候就需要TIME_WAIT状态来维护这次连接,因为假如没有TIME_WAIT,当重传的FIN到达时,TCP连接的信息已经不存在,所以就会重新启动消息应答,会导致对方进入错误的状态而不是正常的终止状态。假如主动关闭方这时候处于TIME_WAIT,那么仍有记录这次连接的信息,就可以正确响应对方重发的FIN了。
- 一个数据报在发送途中或者响应过程中有可能成为残余的数据报,因此必须等待足够长的时间避免新的连接会收到先前连接的残余数据报,而造成状态错误。
sysctl -a
TCP内核参数调优¶
| 序号 | 参数 | 建议值 | ec2值 | 备注 |
|---|---|---|---|---|
| 1.1 | /proc/sys/net/ipv4/tcp_max_syn_backlog | 2048 | ||
| 1.2 | /proc/sys/net/core/somaxconn | 2048 | ||
| 1.3 | /proc/sys/net/ipv4/tcp_abort_on_overflow | 1 | ||
| 2.1 | /proc/sys/net/ipv4/tcp_tw_recycle | 0 | NAT环境必须为0 | |
| 2.2 | /proc/sys/net/ipv4/tcp_tw_reuse | 1 | ||
| 3.1 | /proc/sys/net/ipv4/tcp_syn_retries | 3 | ||
| 3.2 | /proc/sys/net/ipv4/tcp_retries2 | 5 | ||
| 3.3 | /proc/sys/net/ipv4/tcp_slow_start_after_idle | 0 | ||
| - | tcp_fin_timeout | |||
| - | tcp_keepalive_time | |||
| - | tcp_synack_retries | |||
| - | netdev_max_backlog | |||
| - | tcp_max_tw_buckets | |||
| - | /proc/sys/net/ipv4/tcp_syncookies | |||
| - | /proc/sys/net/ipv4/ip_local_port_range |
Tcp协议抓包¶
ssh vagrant@192.168.33.10 'sudo tcpdump -i enp0s8 -e -XX -w - port 8080' | ./Wireshark.exe -k -i -
ssh vagrant@192.168.33.10 'sudo tcpdump -i lo -s0 -c 1000 -nn -w - port 8091' | /Applications/Wireshark.app/Contents/MacOS/Wireshark -k -i -
wireshark关掉相对序号和确认号:
关闭相对序列号/确认号,可以选择Wireshark菜单栏中的 Edit -> Preferences ->protocols ->TCP,去掉Relative sequence number
绘制流功能:
Statistics ->Flow Graph...->TCP flow ->OK
Tcp流量控制与拥塞控制¶
流量控制¶
Sender won’t overflow receiver’s buffer by transmitting too much, too fast. (防止发送方发的太快,耗尽接收方的资源,从而使接收方来不及处理)
- 流量控制的目标是接收端,是怕接收端来不及处理
- 接收端抑制发送端的依据:接收端缓冲区的大小
- 流量控制的机制是丢包
流量控制实现-滑动窗口¶
**滑动窗口**是类似于一个窗口一样的东西,是用来告诉发送端可以发送数据的大小或者说是窗口标记了接收端缓冲区的大小,这样就可以实现。其中窗口指的是一次批量的发送多少数据。
在确认应答策略中,对每一个发送的数据段,都要给一个ACK确认应答,收到ACK后再发送下一个数据段,这样做有一个比较大的缺点,就是性能比较差,尤其是数据往返的时间长的时候。使用滑动窗口,就可以一次发送多条数据,从而就提高了性能。
- 接收端将自己可以接收的缓冲区大小放入TCP首部中的“窗口大小”字段,通过ACK来通知发送端
- 窗口大小字段越大,说明网络的吞吐率越高
- 窗口大小指的是无需等待确认应答而可以继续发送数据的最大值,即就是说不需要接收端的应答,可以一次连续的发送数据
- 操作系统内核为了维护滑动窗口,需要开辟发送缓冲区,来记录当前还有那些数据没有应答,只有确认应答过的数据,才能从缓冲区删掉
- 接收端一旦发现自己的缓冲区快满了,就会将窗口大小设置成一个更小的值通知给发送端,发送端收到这个值后,就会减慢自己的发送速度
- 如果接收端发现自己的缓冲区满了,就会将窗口的大小设置为0,此时发送端将不再发送数据,但是需要定期发送一个窗口探测数据段,使接收端把窗口大小告诉发送端
- 滑动窗口中的数据包含
已发送,但是还没有收到确认的和可以发送但是还没发送的
拥塞控制¶
too many sources sending too much data too fast for network to handle 防止发送方发的太快,使得网络来不及处理,从而导致网络拥塞
拥塞控制机制¶
AIMD\slow start
- slow start: 慢启动
-
A-I: additive(加法的)- increase(增加)
是指执行拥塞避免算法后,在收到对所有报文段的确认后(即经过一个往返时间),就把拥塞窗口cwnd增加一个MSS大小,使拥塞窗口缓慢增大,以防止网络过早出现拥塞 - M-D: multiplicative(乘法的) - decrease(减少)
出现一次超时(即出现一次网络拥塞),就把慢开始门限值ssthresh设置为当前的拥塞窗口值乘以0.5
为什么会有拥塞控制?
流量控制虽然可以高效可靠的传送大量的数据,但是如果在刚开始阶段就发送大量的数据,可能会导致网络拥堵,因为网络上的计算机太多了。
当网络频繁出现拥塞时,ssthresh值就下降的很快,以大大减少注入到网络中的分组数
发送端如何知道已经丢包?
- 定时器超时
- 收到三个重复Ack
如果在超时重传定时器溢出之前,接收到连续的三个重复冗余ACK(其实是收到4个同样的ACK,第一个是正常的,后三个才是冗余的),发送端便知晓哪个报文段在传输过程中丢失了,于是重发该报文段,不需要等待超时重传定时器溢出,大大提高了效率。这便是**快速重传机制**。
流量控制和拥塞控制的区别¶
1.相同点
(1)现象都是丢包; (2)实现机制都是让发送方发的慢一点,发的少一点
2.不同点
(1)丢包位置不同 流量控制丢包位置是在接收端上 拥塞控制丢包位置是在路由器上 (2)作用的对象不同 流量控制的对象是接收方,怕发送方发的太快,使得接收方来不及处理 拥塞控制的对象是网络,怕发送发发的太快,造成网络拥塞,使得网络来不及处理 3.联系
拥塞控制 拥塞控制通常表示的是一个全局性的过程,它会涉及到网络中所有的主机、 所有的路由器和降低网络传输性能的所有因素 流量控制 流量控制发生在发送端和接收端之间,只是点到点之间的控制
停止等待协议和滑动窗口¶
停止等待ARQ协议(stop and wait)¶
当发送窗口和接收窗口都等于1时,就是停止等待协议。发送端给接收端发送数据,等待接收端确认回复ACk,并停止发送新的数据包,开启计时器。数据包在计时器超时之前得到确认,那么计时器就会关闭,并发送下一个数据包。如果计时器超时,发送端就认为数据包丢失或被破坏,需要重新发送之前的数据包,说明数据包在得到确认之前,发送端需要存储数据包的副本。 停止等待协议是发出一个帧后得到确认才发下一个,降低了信道的利用率。
滑动窗口¶
1.发送端和接收端分别设定发送窗口和接收窗口。 2.三次握手的时候,客户端把自己的缓冲区大小也就是窗口大小发送给服务器,服务器回应是也将窗口大小发送给客户端,服务器客户端都知道了彼此的窗口大小。 3.比如主机A的发送窗口大小为5,主机A可以向主机B发送5个单元,如果B缓冲区满了,A就要等待B确认才能继续发送数据。 4.如果缓冲区中有1个报文被进程读取,主机B就会回复ACK给主机A,接收窗口向前滑动,报文中窗口大小为1,就说明A还可以发送1个单元的数据,发送窗口向前滑动,之后等待主机B的确认报文。 只有接收窗口向前滑动并发送了确认时,发送窗口才能向前滑动。
资料¶
FAQ¶
为什么需要 TCP 协议? TCP 工作在哪一层?
IP 层是「不可靠」的,它不保证网络包的交付、不保证网络包的按序交付、也不保证网络包中的数据的完整性。
如果需要保障网络数据包的可靠性,那么就需要由上层(传输层)的 TCP 协议来负责。
因为 TCP 是一个工作在传输层的可靠数据传输的服务,它能确保接收端接收的网络包是无损坏、无间隔、非冗余和按序的。
什么是 TCP ?
TCP 是**面向连接的、可靠的、基于字节流**的传输层通信协议。
- 面向连接: 一定是「一对一」才能连接,不能像 UDP 协议 可以一个主机同时向多个主机发送消息,也就是一对多是无法做到的; 可靠的:**无论的网络链路中出现了怎样的链路变化,TCP 都可以保证一个报文一定能够到达接收端; **字节流: 消息是「没有边界」的,所以无论我们消息有多大都可以进行传输。并且消息是「有序的」,当「前一个」消息没有收到的时候,即使它先收到了后面的字节已经收到,那么也不能扔给应用层去处理,同时对「重复」的报文会自动丢弃。
什么是 TCP 连接?
我们来看看 RFC 793 是如何定义「连接」的:
Connections: The reliability and flow control mechanisms described above require that TCPs initialize and maintain certain status information for each data stream. The combination of this information, including sockets, sequence numbers, and window sizes, is called a connection.
简单来说就是,用于保证可靠性和流量控制维护的某些状态信息,这些信息的组合,包括Socket、序列号和窗口大小称为连接。
所以我们可以知道,建立一个 TCP 连接是需要客户端与服务器端达成上述三个信息的共识。
- Socket:由 IP 地址和端口号组成
- 序列号:用来解决乱序问题等
- 窗口大小:用来做流量控制
如何唯一确定一个 TCP 连接呢?
- 源地址
- 源端口
- 目的地址
- 目的端口
源地址和目的地址的字段(32位)是在 IP 头部中,作用是通过 IP 协议发送报文给对方主机
源端口和目的端口的字段(16位)是在 TCP 头部中,作用是告诉 TCP 协议应该把报文发给哪个进程
有一个 IP 的服务器监听了一个端口,它的 TCP 的最大连接数是多少?
服务端最大并发 TCP 连接数远不能达到理论上限:
- 首先主要是文件描述符限制,Socket 都是文件,所以首先要通过 ulimit 配置文件描述符的数目;
- 另一个是内存限制,每个 TCP 连接都要占用一定内存,操作系统是有限的。
UDP 和 TCP 有什么区别呢?分别的应用场景是?
UDP 不提供复杂的控制机制,利用 IP 提供面向「无连接」的通信服务。
UDP 协议真的非常简,头部只有 8 个字节( 64 位),UDP 的头部格式如下:
- 目标和源端口:主要是告诉 UDP 协议应该把报文发给哪个进程。
- 包长度:该字段保存了 UDP 首部的长度跟数据的长度之和。
- 校验和:校验和是为了提供可靠的 UDP 首部和数据而设计。
TCP 和 UDP 区别:
-
连接
- TCP 是面向连接的传输层协议,传输数据前先要建立连接。
- UDP 是不需要连接,即刻传输数据。
- 服务对象
- TCP 是一对一的两点服务,即一条连接只有两个端点。
- UDP 支持一对一、一对多、多对多的交互通信
-
可靠性
-
TCP 是可靠交付数据的,数据可以无差错、不丢失、不重复、按需到达。
- UDP 是尽最大努力交付,不保证可靠交付数据。
-
拥塞控制、流量控制
- TCP 有拥塞控制和流量控制机制,保证数据传输的安全性。
- UDP 则没有,即使网络非常拥堵了,也不会影响 UDP 的发送速率。
-
首部开销
- TCP 首部长度较长,会有一定的开销,首部在没有使用「选项」字段时是 20 个字节,如果使用了「选项」字段则会变长的。
- UDP 首部只有 8 个字节,并且是固定不变的,开销较小。
TCP 和 UDP 应用场景分布有哪些?
由于 TCP 是面向连接,能保证数据的可靠性交付,因此经常用于:
- FTP 文件传输
- HTTP / HTTPS
由于 UDP 面向无连接,它可以随时发送数据,再加上UDP本身的处理既简单又高效,因此经常用于:
- 包总量较少的通信,如 DNS 、SNMP 等
- 视频、音频等多媒体通信
- 广播通信
为什么 UDP 头部没有「首部长度」字段,而 TCP 头部有「首部长度」字段呢?
原因是 TCP 有可变长的「选项」字段,而 UDP 头部长度则是不会变化的,无需多一个字段去记录 UDP 的首部长度。
为什么 UDP 头部有「包长度」字段,而 TCP 头部则没有「包长度」字段呢?
先说说 TCP 是如何计算负载数据长度:
其中 IP 总长度 和 IP 首部长度,在 IP 首部格式是已知的。TCP 首部长度,则是在 TCP 首部格式已知的,所以就可以求得 TCP 数据的长度。
大家这时就奇怪了问:“ UDP 也是基于 IP 层的呀,那 UDP 的数据长度也可以通过这个公式计算呀? 为何还要有「包长度」呢?”
这么一问,确实感觉 UDP 「包长度」是冗余的。
因为为了网络设备硬件设计和处理方便,首部长度需要是 4字节的整数倍。
如果去掉 UDP 「包长度」字段,那 UDP 首部长度就不是 4 字节的整数倍了,所以这可能是为了补全 UDP 首部长度是 4 字节的整数倍,才补充了「包长度」字段。
二 TCP连接建立¶
TCP 是面向连接的协议,所以使用 TCP 前必须先建立连接,而建立连接是通过三次握手而进行的。
- 一开始,客户端和服务端都处于 CLOSED 状态。先是服务端主动监听某个端口,处于 LISTEN状态
- 客户端会随机初始化序号(client_isn),将此序号置于 TCP 首部的「序号」字段中,同时把 SYN 标志位置为 1 ,表示 SYN 报文。接着把第一个 SYN 报文发送给服务端,表示向服务端发起连接,该报文不包含应用层数据,之后客户端处于 SYN-SENT 状态。
- 服务端收到客户端的 SYN 报文后,首先服务端也随机初始化自己的序号(server_isn),将此序号填入 TCP 首部的「序号」字段中,其次把 TCP 首部的「确认应答号」字段填入 client_isn + 1, 接着把 SYN 和 ACK 标志位置为 1。最后把该报文发给客户端,该报文也不包含应用层数据,之后服务端处于 SYN-RCVD 状态。
-
客户端收到服务端报文后,还要向服务端回应最后一个应答报文,首先该应答报文 TCP 首部 ACK 标志位置为 1 ,其次「确认应答号」字段填入 server_isn + 1 ,最后把报文发送给服务端,这次报文可以携带客户到服务器的数据,之后客户端处于 ESTABLISHED 状态。
-
服务器收到客户端的应答报文后,也进入 ESTABLISHED 状态。
从上面的过程可以发现**第三次握手是可以携带数据的,前两次握手是不可以携带数据的**,这也是面试常问的题。
一旦完成三次握手,双方都处于 ESTABLISHED 状态,此致连接就已建立完成,客户端和服务端就可以相互发送数据了。
** 如何在 Linux 系统中查看 TCP 状态?**
TCP 的连接状态查看,在 Linux 可以通过netstat -napt命令查看。
初始序列号 ISN 是如何随机产生的?
起始 ISN 是基于时钟的,每 4 毫秒 + 1,转一圈要 4.55 个小时。
RFC1948 中提出了一个较好的初始化序列号 ISN 随机生成算法。
ISN = M + F (localhost, localport, remotehost, remoteport)
- M 是一个计时器,这个计时器每隔 4 毫秒加 1。
- F 是一个 Hash 算法,根据源 IP、目的 IP、源端口、目的端口生成一个随机数值。要保证 Hash 算法不能被外部轻易推算得出,用 MD5 算法是一个比较好的选择。
既然 IP 层会分片,为什么 TCP 层还需要 MSS 呢?
我们先来认识下 MTU 和 MSS:
- MTU:一个网络包的最大长度,以太网中一般为 1500 字节;
- MSS:除去 IP 和 TCP 头部之后,一个网络包所能容纳的 TCP 数据的最大长度;
如果在 TCP 的整个报文(头部 + 数据)交给 IP 层进行分片,会有什么异常呢?
当 IP 层有一个超过 MTU 大小的数据(TCP 头部 + TCP 数据)要发送,那么 IP 层就要进行分片,把数据分片成若干片,保证每一个分片都小于 MTU。把一份 IP 数据报进行分片以后,由目标主机的 IP 层来进行重新组装后,在交给上一层 TCP 传输层。
这看起来井然有序,但这存在隐患的,那么**当如果一个 IP 分片丢失,整个 IP 报文的所有分片都得重传**。
因为 IP 层本身没有超时重传机制,它由传输层的 TCP 来负责超时和重传。
当接收方发现 TCP 报文(头部 + 数据)的某一片丢失后,则不会响应 ACK 给对方,那么发送方的 TCP 在超时后,就会重发「整个 TCP 报文(头部 + 数据)」。
因此,可以得知由 IP 层进行分片传输,是非常没有效率的。
所以,为了达到最佳的传输效能 TCP 协议在建立连接的时候通常要协商双方的 MSS 值,当 TCP 层发现数据超过 MSS 时,则就先会进行分片,当然由它形成的 IP 包的长度也就不会大于 MTU ,自然也就不用 IP 分片了。
经过 TCP 层分片后,如果一个 TCP 分片丢失后,进行重发时也是以 MSS 为单位,而不用重传所有的分片,大大增加了重传的效率。
什么是 SYN 攻击?如何避免 SYN 攻击?
我们都知道 TCP 连接建立是需要三次握手,假设攻击者短时间伪造不同 IP 地址的 SYN 报文,服务端每接收到一个 SYN 报文,就进入SYN_RCVD 状态,但服务端发送出去的 ACK + SYN 报文,无法得到未知 IP 主机的 ACK 应答,久而久之就会占满服务端的 SYN 接收队列(未连接队列),使得服务器不能为正常用户服务。
避免 SYN 攻击方式一:
其中一种解决方式是通过修改 Linux 内核参数,控制队列大小和当队列满时应做什么处理
-
当网卡接收数据包的速度大于内核处理的速度时,会有一个队列保存这些数据包。控制该队列的最大值如下参数: > net.core.netdev_max_backlog
-
SYN_RCVD 状态连接的最大个数: > net.ipv4.tcp_max_syn_backlog
-
超出处理能时,对新的 SYN 直接回报 RST,丢弃连接: > net.ipv4.tcp_abort_on_overflow
避免 SYN 攻击方式二:
我们先来看下Linux 内核的 SYN (未完成连接建立)队列与 Accpet (已完成连接建立)队列是如何工作的?
正常流程:
- 当服务端接收到客户端的 SYN 报文时,会将其加入到内核的「 SYN 队列」;
- 接着发送 SYN + ACK 给客户端,等待客户端回应 ACK 报文;
- 服务端接收到 ACK 报文后,从「 SYN 队列」移除放入到「 Accept 队列」;
- 应用通过调用 accpet() socket 接口,从「 Accept 队列」取出的连接。
应用程序过慢:
如果应用程序过慢时,就会导致「 Accept 队列」被占满。
受到 SYN 攻击:
如果不断受到 SYN 攻击,就会导致「 SYN 队列」被占满。
tcp_syncookies 的方式可以应对 SYN 攻击的方法:
net.ipv4.tcp_syncookies = 1
- 当 「 SYN 队列」满之后,后续服务器收到 SYN 包,不进入「 SYN 队列」;
- 计算出一个 cookie 值,再以 SYN + ACK 中的「序列号」返回客户端,
- 服务端接收到客户端的应答报文时,服务器会检查这个 ACK 包的合法性。如果合法,直接放入到「 Accept 队列」。
- 最后应用通过调用 accpet() socket 接口,从「 Accept 队列」取出的连接。
三 TCP连接断开¶
TCP 四次挥手过程和状态变迁:
TCP 断开连接是通过四次挥手方式。双方都可以主动断开连接,断开连接后主机中的「资源」将被释放。
- 客户端打算关闭连接,此时会发送一个 TCP 首部 FIN 标志位被置为 1 的报文,也即 FIN 报文,之后客户端进入 FIN_WAIT_1 状态。
- 服务端收到该报文后,就向客户端发送 ACK 应答报文,接着服务端进入 CLOSED_WAIT 状态。
- 客户端收到服务端的 ACK 应答报文后,之后进入 FIN_WAIT_2 状态。
- 等待服务端处理完数据后,也向客户端发送 FIN 报文,之后服务端进入 LAST_ACK 状态。
- 客户端收到服务端的 FIN 报文后,回一个 ACK 应答报文,之后进入 TIME_WAIT 状态
- 服务器收到了 ACK 应答报文后,就进入了 CLOSE 状态,至此服务端已经完成连接的关闭。
- 客户端在经过 2MSL 一段时间后,自动进入 CLOSE 状态,至此客户端也完成连接的关闭。
每个方向都需要一个 FIN 和一个 ACK,因此通常被称为四次挥手。这里一点需要注意是:主动关闭连接的,才有 TIME_WAIT 状态。
为什么挥手需要四次?
- 关闭连接时,客户端向服务端发送 FIN 时,仅仅表示客户端不再发送数据了但是还能接收数据。
- 服务器收到客户端的 FIN 报文时,先回一个 ACK 应答报文,而服务端可能还有数据需要处理和发送,等服务端不再发送数据时,才发送 FIN 报文给客户端来表示同意现在关闭连接。
从上面过程可知,服务端通常需要等待完成数据的发送和处理,所以服务端的 ACK 和 FIN 一般都会分开发送,从而比三次握手导致多了一次。
为什么 TIME_WAIT 等待的时间是 2MSL?
MSL 是 Maximum Segment Lifetime,报文最大生存时间,它是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。因为 TCP 报文基于是 IP 协议的,而 IP 头中有一个 TTL 字段,是 IP 数据报可以经过的最大路由数,每经过一个处理他的路由器此值就减 1,当此值为 0 则数据报将被丢弃,同时发送 ICMP 报文通知源主机。
MSL 与 TTL 的区别: MSL 的单位是时间,而 TTL 是经过路由跳数。所以 MSL 应该要大于等于 TTL 消耗为 0 的时间,以确保报文已被自然消亡。
TIME_WAIT 等待 2 倍的 MSL,比较合理的解释是: 网络中可能存在来自发送方的数据包,当这些发送方的数据包被接收方处理后又会向对方发送响应,所以**一来一回需要等待 2 倍的时间**。
2MSL 的时间是从客户端接收到 FIN 后发送 ACK 开始计时的。如果在 TIME-WAIT 时间内,因为客户端的 ACK 没有传输到服务端,客户端又接收到了服务端重发的 FIN 报文,那么 2MSL 时间将重新计时。
在 Linux 系统里 2MSL 默认是 60 秒,那么一个 MSL 也就是 30 秒。Linux 系统停留在 TIME_WAIT 的时间为固定的 60 秒。
** 为什么需要 TIME_WAIT 状态?**
主动发起关闭连接的一方,才会有 TIME-WAIT 状态。需要 TIME-WAIT 状态,主要是两个原因:
- 防止具有相同「四元组」的「旧」数据包被收到;
- 保证「被动关闭连接」的一方能被正确的关闭,即保证最后的 ACK 能让被动关闭方接收,从而帮助其正常关闭;
原因一:防止旧连接的数据包
假设 TIME-WAIT 没有等待时间或时间过短,被延迟的数据包抵达后会发生什么呢?
- 如上图黄色框框服务端在关闭连接之前发送的 SEQ = 301 报文,被网络延迟了。
- 这时有相同端口的 TCP 连接被复用后,被延迟的 SEQ = 301 抵达了客户端,那么客户端是有可能正常接收这个过期的报文,这就会产生数据错乱等严重的问题。
所以,TCP 就设计出了这么一个机制,经过 2MSL 这个时间,足以让两个方向上的数据包都被丢弃,使得原来连接的数据包在网络中都自然消失,再出现的数据包一定都是新建立连接所产生的。
原因二:保证连接正确关闭
在 RFC 793 指出 TIME-WAIT 另一个重要的作用是:
TIME-WAIT - represents waiting for enough time to pass to be sure the remote TCP received the acknowledgment of its connection termination request.
也就是说,TIME-WAIT 作用是等待足够的时间以确保最后的 ACK 能让被动关闭方接收,从而帮助其正常关闭。
假设 TIME-WAIT 没有等待时间或时间过短,断开连接会造成什么问题呢?
- 如上图红色框框客户端四次挥手的最后一个 ACK 报文如果在网络中被丢失了,此时如果客户端 TIME-WAIT 过短或没有,则就直接进入了 CLOSE 状态了,那么服务端则会一直处在 LASE-ACK 状态。
- 当客户端发起建立连接的 SYN 请求报文后,服务端会发送 RST 报文给客户端,连接建立的过程就会被终止。
如果 TIME-WAIT 等待足够长的情况就会遇到两种情况:
- 服务端正常收到四次挥手的最后一个 ACK 报文,则服务端正常关闭连接。
- 服务端没有收到四次挥手的最后一个 ACK 报文时,则会重发 FIN 关闭连接报文并等待新的 ACK 报文。
所以客户端在 TIME-WAIT 状态等待 2MSL 时间后,就可以保证双方的连接都可以正常的关闭。
TIME_WAIT 过多有什么危害?
如果服务器有处于 TIME-WAIT 状态的 TCP,则说明是由服务器方主动发起的断开请求。
过多的 TIME-WAIT 状态主要的危害有两种:
- 第一是内存资源占用;
- 第二是对端口资源的占用,一个 TCP 连接至少消耗一个本地端口;
第二个危害是会造成严重的后果的,要知道,端口资源也是有限的,一般可以开启的端口为 32768~61000,也可以通过如下参数设置指定
net.ipv4.ip_local_port_range
如果服务端 TIME_WAIT 状态过多,占满了所有端口资源,则会导致无法创建新连接。
如何优化 TIME_WAIT?
方式一:net.ipv4.tcp_tw_reuse 和 tcp_timestamps
如下的 Linux 内核参数开启后,则可以复用处于 TIME_WAIT 的 socket 为新的连接所用。
net.ipv4.tcp_tw_reuse = 1
使用这个选项,还有一个前提,需要打开对 TCP 时间戳的支持,即
net.ipv4.tcp_timestamps=1(默认即为 1) 这个时间戳的字段是在 TCP 头部的「选项」里,用于记录 TCP 发送方的当前时间戳和从对端接收到的最新时间戳。
由于引入了时间戳,我们在前面提到的 2MSL 问题就不复存在了,因为重复的数据包会因为时间戳过期被自然丢弃。
注意: net.ipv4.tcp_tw_reuse要慎用,因为使用了它就必然要打开时间戳的支持 net.ipv4.tcp_timestamps,当客户端与服务端主机时间不同步时,客户端的发送的消息会被直接拒绝掉
方式二:net.ipv4.tcp_max_tw_buckets
这个值默认为 18000,当系统中处于 TIME_WAIT 的连接一旦超过这个值时,系统就会将所有的 TIME_WAIT 连接状态重置。
这个方法过于暴力,而且治标不治本,带来的问题远比解决的问题多,不推荐使用
方式三:程序中使用 SO_LINGER
我们可以通过设置 socket 选项,来设置调用 close 关闭连接行为。
struct linger so_linger;
so_linger.l_onoff = 1;
so_linger.l_linger = 0;
setsockopt(s, SOL_SOCKET, SO_LINGER, &so_linger,sizeof(so_linger));
但这为跨越TIME_WAIT状态提供了一个可能,不过是一个非常危险的行为,不值得提倡
如果已经建立了连接,但是客户端突然出现故障了怎么办?
TCP 有一个机制是保活机制。这个机制的原理是这样的:
定义一个时间段,在这个时间段内,如果没有任何连接相关的活动,TCP 保活机制会开始作用,每隔一个时间间隔,发送一个探测报文,该探测报文包含的数据非常少,如果连续几个探测报文都没有得到响应,则认为当前的 TCP 连接已经死亡,系统内核将错误信息通知给上层应用程序。
在 Linux 内核可以有对应的参数可以设置保活时间、保活探测的次数、保活探测的时间间隔,以下都为默认值:
- tcp_keepalive_time=7200:表示保活时间是 7200 秒(2小时),也就 2 小时内如果没有任何连接相关的活动,则会启动保活机制
- tcp_keepalive_intvl=75:表示每次检测间隔 75 秒;
- tcp_keepalive_probes=9:表示检测 9 次无响应,认为对方是不可达的,从而中断本次的连接。
也就是说在 Linux 系统中,最少需要经过 2 小时 11 分 15 秒才可以发现一个「死亡」连接。
这个时间是有点长的,我们也可以根据实际的需求,对以上的保活相关的参数进行设置。
如果开启了 TCP 保活,需要考虑以下几种情况:
-
第一种,对端程序是正常工作的。当 TCP 保活的探测报文发送给对端, 对端会正常响应,这样 TCP 保活时间会被重置,等待下一个 TCP 保活时间的到来。
-
第二种,对端程序崩溃并重启。当 TCP 保活的探测报文发送给对端后,对端是可以响应的,但由于没有该连接的有效信息,会产生一个 RST 报文,这样很快就会发现 TCP 连接已经被重置。
-
第三种,是对端程序崩溃,或对端由于其他原因导致报文不可达。当 TCP 保活的探测报文发送给对端后,石沉大海,没有响应,连续几次,达到保活探测次数后,TCP 会报告该 TCP 连接已经死亡。
Socket 编程¶
针对 TCP 应该如何 Socket 编程?
- 服务端和客户端初始化 socket,得到文件描述符;
- 服务端调用 bind,将绑定在 IP 地址和端口;
- 服务端调用 listen,进行监听;
- 服务端调用 accept,等待客户端连接;
- 客户端调用 connect,向服务器端的地址和端口发起连接请求;
- 服务端 accept 返回用于传输的 socket 的文件描述符;
- 客户端调用 write 写入数据;服务端调用 read 读取数据;
- 客户端断开连接时,会调用 close,那么服务端 read 读取数据的时候,就会读取到了 EOF,待处理完数据后,服务端调用 close,表示连接关闭。
** listen 时候参数 backlog 的意义?**
Linux内核中会维护两个队列:
- 未完成连接队列(SYN 队列):接收到一个 SYN 建立连接请求,处于 SYN_RCVD 状态;
- 已完成连接队列(Accpet 队列):已完成 TCP 三次握手过程,处于 ESTABLISHED 状态;
int listen (int socketfd, int backlog)
- 参数一 socketfd 为 socketfd 文件描述符
- 参数二 backlog,这参数在历史有一定的变化
在 早期 Linux 内核 backlog 是 SYN 队列大小,也就是未完成的队列大小。
在 Linux 内核 2.2 之后,backlog 变成 accept 队列,也就是已完成连接建立的队列长度,所以现在通常认为 backlog 是 accept 队列。
accept 发送在三次握手的哪一步?
我们先看看客户端连接服务端时,发送了什么?
- 客户端的协议栈向服务器端发送了 SYN 包,并告诉服务器端当前发送序列号 client_isn,客户端进入 SYNC_SENT 状态;
- 服务器端的协议栈收到这个包之后,和客户端进行 ACK 应答,应答的值为 client_isn+1,表示对 SYN 包 client_isn 的确认,同时服务器也发送一个 SYN 包,告诉客户端当前我的发送序列号为 server_isn,服务器端进入 SYNC_RCVD 状态;
- 客户端协议栈收到 ACK 之后,使得应用程序从 connect 调用返回,表示客户端到服务器端的单向连接建立成功,客户端的状态为 ESTABLISHED,同时客户端协议栈也会对服务器端的 SYN 包进行应答,应答数据为 server_isn+1;
- 应答包到达服务器端后,服务器端协议栈使得 accept 阻塞调用返回,这个时候服务器端到客户端的单向连接也建立成功,服务器端也进入 ESTABLISHED 状态。
从上面的描述过程,我们可以得知客户端 connect 成功返回是在第二次握手,服务端 accept 成功返回是在三次握手成功之后。
客户端调用 close 了,连接是断开的流程是什么?
我们看看客户端主动调用了 close,会发生什么?
- 客户端调用 close,表明客户端没有数据需要发送了,则此时会向服务端发送 FIN 报文,进入 FIN_WAIT_1 状态;
- 服务端接收到了 FIN 报文,TCP 协议栈会为 FIN 包插入一个文件结束符 EOF 到接收缓冲区中,应用程序可以通过 read 调用来感知这个 FIN 包。这个 EOF 会被放在已排队等候的其他已接收的数据之后,这就意味着服务端需要处理这种异常情况,因为 EOF 表示在该连接上再无额外数据到达。此时,服务端进入 CLOSE_WAIT 状态;
- 接着,当处理完数据后,自然就会读到 EOF,于是也调用 close 关闭它的套接字,这会使得会发出一个 FIN 包,之后处于 LAST_ACK 状态;
- 客户端接收到服务端的 FIN 包,并发送 ACK 确认包给服务端,此时客户端将进入 TIME_WAIT 状态;
- 服务端收到 ACK 确认包后,就进入了最后的 CLOSE 状态;
- 客户端进过 2MSL 时间之后,也进入 CLOSED 状态;
TCP全连接队列占满问题分析及优化¶
从图中明显可以看出建立 TCP 连接的时候,有两个队列:syns queue(半连接队列)和accept queue(全连接队列),分别在第一次握手和第三次握手。
半连接队列¶
保存 SYN_RECV 状态的连接。
控制参数:
- 半连接队列的大小:min(backlog, 内核参数 net.core.somaxconn,内核参数tcp_max_syn_backlog).
- net.ipv4.tcp_max_syn_backlog:能接受 SYN 同步包的最大客户端数量,即半连接上限;
- tcp_syncookies:当出现SYN等待队列溢出时,启用cookies来处理,可防范少量SYN攻击,默认为0,表示关闭;
accept队列-全连接队列¶
保存 ESTABLISHED 状态的连接。
控制参数:
- 全连接队列的大小:min(backlog, /proc/sys/net/core/somaxconn),意思是取backlog 与 somaxconn 两值的最小值,net.core.somaxconn 定义了系统级别的全连接队列最大长度,而 backlog 只是应用层传入的参数,所以 backlog 值尽量小于net.core.somaxconn;
- net.core.somaxconn(内核态参数,系统中每一个端口最大的监听队列的长度);
- net.core.netdev_max_backlog(每个网络接口接收数据包的速率比内核处理这些包的速率快时,允许送到队列的数据包的最大数目);
- ServerSocket(int port, int backlog) 代码中的backlog参数
- net.ipv4.tcp_abort_on_overflow = 0,此值为 0 表示握手到第三步时全连接队列满时则扔掉 client 发过来的 ACK,此值为 1 则说明握手到第三步时全连接队列满时则返回 reset 给客户端。
全连接队列容量不足导致大量单边连接产生。查看TCP连接溢出情况统计:
如果看出overflow 的值一直在增加,那么说明 server 的TCP 全连接队列的确是满了。接下来全连接队列占用情况:
上图参数说明: - Recv-Q:全连接当前长度 - Send-Q:如果连接不是在建立状态,则是当前全连接最大队列长度
从上面可以看到8123端口应用的全连接队列大小128。这是我们查看
发现somaxconn的值是65535,是足够大的,那说明应用代码中的 backlog 的值是128,这个值太小了,应该在程序代码中加大全连接队列的长度。
此外我们还可以查看应用程序的网络队列阻塞情况:
参数说明:
- Send-Q:发送队列中没有被远程主机确认的 bytes 数;
- Recv-Q:指收到的数据还在缓存中,还没被进程读取,这个值就是还没被进程读取的bytes;一般是CPU处理不过来导致的。
全连接队列满了,会造成说明问题呢?¶
全连接队列容量不足导致大量单边连接产生。accept queue 已满,服务端会直接丢弃后续ACK请求;客户端误以为连接已建立,开始调用等待至超时;服务器则会等待ACK超时,会重传SYN+ACK 给客户端,重传次数为net.ipv4.tcp_synack_retries。若在重传过程中,accept queue队列一直满的情况下,那么次数达到net.ipv4.tcp_synack_retries后,服务端会直接丢弃此连接接。由于客户端意味连接已连接,那么客户端push数据时候,就会接收到服务端应答的RST,此时服务器方日志抛出“Connection reset by peer”错误。具体流程可以参加下面流程:
资料¶
HTTP协议¶
超文本传输协议(HTTP)是一种通信协议,它允许将超文本标记语言(HTML)文档从Web服务器传送到客户端的浏览器。目前广泛使用的是HTTP/1.1 版本
HTTP协议是无状态的
HTTP消息的结构¶
请求消息¶
Request 消息分为3部分,第一部分叫Request line, 第二部分叫Request header, 第三部分是body. header和body之间有个空行, 结构如下图
注意: 请求头和请求体之间用换行隔开(\r\n)
响应消息¶
第一部分叫Response line, 第二部分叫Response header,第三部分是body. header字段之间要有空行(\r\n),header和body之间也有个空行, 结构如下图
HTTP Status Code¶
HTTP cache¶
cache相关的头¶
Cache-Control¶
选项值有:
- Public :所有内容都将被缓存,在响应头中设置
- Private :内容只缓存到私有服务器中,在响应头中设置
- no-cache :不是不缓存,而是缓存需要校验。
- no-store :所有内容都不会被缓存到缓存或Internet临时文件中,在响应头中设置 must-revalidation/proxy-revalidation :如果缓存的内容失效,请求必须发送到服务器/代理以进行重新验证,在请求头中设置
- max-age=xxx :缓存的内容将在xxx秒后失效,这个选项只在HTTP1.1中可用,和Last-Modified一起使用时优先级较高,在响应头中设置
Expires¶
它通常的使用格式是Expires:Fri ,24 Dec 2027 04:24:07 GMT,后面跟的是日期和时间,超过这个时间后,缓存的内容将失效,浏览器在发出请求之前会先检查这个页面的这个字段,查看页面是否已经过期,过期了就重新向服务器发起请求
Last-Modified / If-Modified¶
它一般用于表示一个服务器上的资源最后的修改时间,资源可以是静态或动态的内容, 通过这个最后修改时间可以判断当前请求的资源是否是最新的。 一般服务端在响应头中返回一个Last-Modified字段,告诉浏览器这个页面的最后修改时间, 浏览器再次请求时会在请求头中增加一个If-Modified字段,询问当前缓存的页面是否是最新的, 如果是最新的就返回304状态码,告诉浏览器是最新的,服务器也不会传输新的数据
Etag/If-None-Match¶
一般用于当Cache-Control:no-cache时,用于验证缓存有效性。
它的作用是让服务端给每个页面分配一个唯一 的编号,然后通过这个编号来区分当前这个页面是否是最新的, 这种方式更加灵活,但是后端如果有多台Web服务器时不太好处理,因为每个Web服务器都要记住网站的所有资源,否则浏览器返回这个编号就没有意义了
跨域分类¶
CORS跨域访问的请求分三种: - simple request
如果一个请求没有包含任何自定义请求头,而且它所使用HTTP动词是GET,HEAD或POST之一,那么它就是一个Simple Request。但是在使用POST作为请求的动词时,该请求的Content-Type需要是application/x-www-form-urlencoded,multipart/form-data或text/plain之一。
-
preflighted request(预请求)
如果一个请求包含了任何自定义请求头,或者它所使用的HTTP动词是GET,HEAD或POST之外的任何一个动词,那么它就是一个Preflighted Request。如果POST请求的Content-Type并不是application/x-www-form-urlencoded,multipart/form-data或text/plain之一,那么其也是Preflighted Request。
-
requests with credential 一般情况下,一个跨域请求不会包含当前页面的用户凭证。一旦一个跨域请求包含了当前页面的用户凭证,那么其就属于Requests with Credential。
对于simple request 只需要在后端程序处理时候设Access-Control-Allow-Orgin头就可以了。
对于preflighted request 每次都会请求2次,第一次options(firefox下看不到这次请求,chrome可以看见)。如果只能跟simple request 一样只设置access-control-allow-orgin是不行的。 还必须处理$_SERVER['REQUEST_METHOD'] == 'OPTIONS',2者都必须处理
Cors相关的HTTP响应头¶
-
Access-Control-Allow-Origin
origin参数指定一个允许向该服务器提交请求的URI.对于一个不带有credentials的请求,可以指定为'*',表示允许来自所有域的请求。如果服务器端指定了域名,而不是'*',那么响应头的Vary值里必须包含Origin.它告诉客户端: 响应是根据请求头里的Origin的值来返回不同的内容的.
-
Access-Control-Expose-Headers
设置浏览器允许访问的服务器的头信息的白名单
这样, X-My-Custom-Header 和 X-Another-Custom-Header这两个头信息,都可以被浏览器得到. -
Access-Control-Max-Age
这个头告诉我们这次预请求的结果的有效期是多久,如下:
delta-seconds 参数表示,允许这个预请求的参数缓存的秒数,在此期间,不用发出另一条预检请求.
-
Access-Control-Allow-Credentials
告知客户端,当请求的credientials属性是true的时候,响应是否可以被得到.当它作为预请求的响应的一部分时,它用来告知实际的请求是否使用了credentials。
注意,简单的GET请求不会预检,所以如果一个请求是为了得到一个带有credentials的资源,而响应里又没有Access-Control-Allow-Credentials头信息,那么说明这个响应被忽略了.
-
Access-Control-Allow-Methods
指明资源可以被请求的方式有哪些(一个或者多个). 这个响应头信息在客户端发出预检请求的时候会被返回. 上面有相关的例子.
-
Access-Control-Allow-Headers
多个HTTP请求头, 用逗号分隔
Cors相关的HTTP请求头¶
-
Origin
表明发送请求或者预请求的域
参数origin是一个URI,告诉服务器端,请求来自哪里.它不包含任何路径信息,只是服务器名.
注意: Origin的值可以是一个空字符串,这是很有用的. 注意,不仅仅是跨域请求,普通请求也会带有ORIGIN头信息.
-
Access-Control-Request-Method
在发出预检请求时带有这个头信息,告诉服务器在实际请求时会使用的请求方式
-
Access-Control-Request-Headers
在发出预检请求时带有这个头信息,告诉服务器在实际请求时会携带的自定义头信息.如有多个,可以用逗号分开.
HTTPS的四次握手过程¶
https握手过程分为两步:
- 通过CA验证服务端的证书是否真实
- 交换客户端和服务端的对称加密秘钥,以后数据传输,靠这两个进行加密
引入CA目的是为了防止中间人攻击。即攻击者伪造成服务端,然后发送假的证书。
FAQ¶
现代浏览器在与服务器建立了一个 TCP 连接后是否会在一个 HTTP 请求完成后断开?什么情况下会断开?¶
默认情况下建立 TCP 连接不会断开,只有在请求报头中声明 Connection: close 才会在请求完成后关闭连接。
在 HTTP/1.0 中,一个服务器在发送完一个 HTTP 响应后,会断开 TCP 链接。但是这样每次请求都会重新建立和断开 TCP 连接,代价过大。所以虽然标准中没有设定,某些服务器对 Connection: keep-alive 的 Header 进行了支持。意思是说,完成这个 HTTP 请求之后,不要断开 HTTP 请求使用的 TCP 连接。这样的好处是连接可以被重新使用,之后发送 HTTP 请求的时候不需要重新建立 TCP 连接,以及如果维持连接,那么 SSL 的开销也可以避免,两张图片是短时间内两次访问 https://www.github.com 的时间统计:
初始化连接和 SSL 开销消失了,说明使用的是同一个 TCP 连接。
持久连接:既然维持 TCP 连接好处这么多,HTTP/1.1 就把 Connection 头写进标准,并且默认开启持久连接,除非请求中写明 Connection: close,那么浏览器和服务器之间是会维持一段时间的 TCP 连接,不会一个请求结束就断掉。
一个 TCP 连接可以对应几个 HTTP 请求?¶
如果维持连接,一个 TCP 连接是可以发送多个 HTTP 请求的。
一个 TCP 连接中 HTTP 请求发送可以一起发送么(比如一起发三个请求,再三个响应一起接收)?¶
在 HTTP/1.1 存在 Pipelining 技术可以完成这个多个请求同时发送,但是由于浏览器默认关闭,所以可以认为这是不可行的。在 HTTP2 中由于 Multiplexing 特点的存在,多个 HTTP 请求可以在同一个 TCP 连接中并行进行
HTTP/1.1 存在一个问题,单个 TCP 连接在同一时刻只能处理一个请求,意思是说:两个请求的生命周期不能重叠,任意两个 HTTP 请求从开始到结束的时间在同一个 TCP 连接里不能重叠。
虽然 HTTP/1.1 规范中规定了 Pipelining 来试图解决这个问题,但是这个功能在浏览器中默认是关闭的。
但是,HTTP2 提供了 Multiplexing 多路传输特性,可以在一个 TCP 连接中同时完成多个 HTTP 请求。
绿色是发起请求到请求返回的等待时间,蓝色是响应的下载时间,可以看到都是在同一个 Connection,并行完成的。
为什么有的时候刷新页面不需要重新建立 SSL 连接?¶
TCP 连接有的时候会被浏览器和服务端维持一段时间。TCP 不需要重新建立,SSL 自然也会用之前的。
浏览器对同一 Host 建立 TCP 连接到数量有没有限制?¶
有。Chrome 最多允许对同一个 Host 建立六个 TCP 连接。不同的浏览器有一些区别。
资料¶
Ended: 计算机网络
数据库 ↵
mysql ↵
概览¶
MySQL 是一个关系型数据库管理系统,由瑞典 MySQL AB 公司开发,目前属于 Oracle 公司。MySQL 是一种关联数据库管理系统,将数据保存在不同的表中,而不是将所有数据放在一个大仓库内,这样就增加了速度并提高了灵活性。
逻辑架构介绍¶
从上到下,连接层,服务层,引擎层,存储层
## Mysql安装部署
安装、部署略
配置文件说明
## MySQL查询过程
当向MySQL发送一个请求的时候,MySQL到底做了些什么呢?
## 存储引擎
### 查看
查看支持哪些引擎?
show engines;
查看当前默认引擎?
show variables like '%storage_engine%';
MyISAM和InnoDB对比¶
事务¶
ACID¶
-
原子性: 事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用;
-
一致性: 执行事务前后,数据保持一致;
-
隔离性: 并发访问数据库时,一个用户的事物不被其他事物所干扰,各并发事务之间数据库是独立的;
-
持久性: 一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库 发生故障也不应该对其有任何影响。
隔离级别¶
事务隔离级别 (transaction isolation levels),隔离级别就是对对事务并发控制的等级。
很多 DBMS 定义了不同的 “事务隔离等级” 来控制锁的程度,多数的数据库事务都避免高等级的隔离等级 (如可序列化) 从而减少对系统的锁的开销,高的隔离级别往往会增加死锁发生的几率。
隔离级别配置¶
InnoDB 默认是可重复读的 (REPEATABLE READ),提供 SQL-92 标准所描述的所有四个事务隔离级别,可以在启动时用 --transaction-isolation 选项设置,也可以配置文件中设置。
$ cat /etc/my.cnf
[mysqld]
transaction-isolation = {READ-UNCOMMITTED | READ-COMMITTED | REPEATABLE-READ | SERIALIZABLE}
用户可以用 SET TRANSACTION 语句改变单个会话或者所有新进连接的隔离级别,语法如下:
mysql> SET autocommit=0;
mysql> SET [SESSION | GLOBAL] TRANSACTION ISOLATION LEVEL
{READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE}
如果使用 GLOBAL 关键字,当然需要 SUPER 权限,则从设置时间点开始创建的所有新连接均采用该默认事务级别,不过原有链接事务隔离级别不变。
可以用下列语句查询全局和会话事务隔离级别。
mysql> SHOW VARIABLES LIKE 'tx_isolation';
mysql> SELECT @@global.tx_isolation;
mysql> SELECT @@session.tx_isolation;
mysql> SELECT @@tx_isolation;
读取异常¶
在 SQL 92 规范的定义中,规定了四种隔离级别,同时对可能出现的三种现象进行了说明(不包含如下的丢失更新)。
Lost Update¶
丢失更新,当两个事务读取相同数据,然后都尝试更新原来的数据成新的值,此时,第二个事务可能完全覆盖掉第一个所完成的更新。
丢失更新是唯一一个用户可能在所有情况下都想避免的行为,在 SQL 92 中甚至没有提及。
Dirty Read - 脏读¶
一个事务中读取到另一个事务未提交的数据。例如,事务 T1 读取到另一个事务 T2 未提交的数据,如果 T2 回滚,则 T1 相当于读取到了一个被认为不可能出现的值。
Non-Repeatable Read - 不可重复读¶
在一个事务中,当重复读取同一条记录时,发现该记录的结果不同或者已经被删除了;如在事务 T1 中读取了一行,接着 T2 修改或者删除了该行并提交,那么当 T1 尝试读取新的值时,就会发现改行的值已经修改或者被删除。
Phantom Read - 幻读¶
通常是指在一个事务中,当重复查询一个结果集时,返回的两个不同的结果集,可能是由于另一个事务插入或者删除了一些记录。
例如,事务 T1 读取一个结果集,T2 修改了该结果集中的部分记录 (例如插入一条记录),T1 再次读取时发现与之前的结果不同 (多出来一条记录),就像产生幻觉一样。
不可重复读与幻读的区别:
不可重复读的重点是修改:同样的条件, 你读取过的数据, 再次读取出来发现值不一样了,其只需要锁住满足条件的记录
幻读的重点在于新增或者删除:同样的条件, 第1次和第2次读出来的记录数不一样,要锁住满足条件及其相近的记录
如果使用锁机制来实现这两种隔离级别,在可重复读中,该sql第一次读取到数据后,就将这些数据加锁,其它事务无法修改这些数据,就可以实现可重复读了。但这种方法却无法锁住insert的数据,所以当事务A先前读取了数据,或者修改了全部数据,事务B还是可以insert数据提交,这时事务A就会 发现莫名其妙多了一条之前没有的数据,这就是幻读,不能通过行锁来避免。需要Serializable隔离级别 ,读用读锁,写用写锁,读锁和写锁互斥,这么做可以有效的避免幻读、不可重复读、脏读等问题,但会极大的降低数据库的并发能力,因为解决幻读需要锁表了。
mysql是以乐观锁为理论基础的MVCC(多版本并发控制)来避免不可重复度和幻读的。
| 隔离级别 | 脏读 | 不可重复读取 | 幻影数据行 |
|---|---|---|---|
| READ UNCOMMITTED(RU) - 读未提交 | YES | YES | YES |
| READ COMMITTED(RC) - 读已提交 | NO | YES | YES |
| REPEATABLE READ(RR) - 可重复读 | NO | NO | YES |
| SERIALIZABLE(SZ) - 串行化 | NO | NO | NO |
事务超时¶
与事务超时相关的变量可以参考。
----- 设置锁超时时间,单位为秒,默认50s
mysql> SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
+--------------------------+-------+
| Variable_name | Value |
+--------------------------+-------+
| innodb_lock_wait_timeout | 50 |
+--------------------------+-------+
1 row in set (0.00 sec)
----- 超时后的行为,默认OFF,详见如下介绍
mysql> SHOW VARIABLES LIKE 'innodb_rollback_on_timeout';
+----------------------------+-------+
| Variable_name | Value |
+----------------------------+-------+
| innodb_rollback_on_timeout | OFF |
+----------------------------+-------+
1 row in set (0.02 sec)
innodb_rollback_on_timeout 变量默认值为 OFF,如果事务因为加锁超时,会回滚上一条语句执行的操作;如果设置 ON,则整个事务都会回滚。
MVCC¶
MVCC(Multi-Version Concurrency Control)即多版本并发控制,MVCC 是一种并发控制的方法,以乐观锁的方式解决事务中不可重复读和幻读的问题。
MVCC是行级锁的一个变种,但是它在很多情况下避免了加锁操作,因此开销更低。MVCC实现了非阻塞的读操作,写操作也只是锁定必要的行。MVCC会保存某个时间点上的数据快照(Snapshot)。这意味着事务可以看到一个一致的数据视图,不管他们需要跑多久。这同时也意味着不同的事务在同一个时间点看到的同一个表的数据可能是不同的。
MySQL的InnoDB存储引擎实现MVCC的策略¶
InnoDB默认事务隔离级别是可重复读。InnoDB的MVCC,是通过在每行记录后面保存两个隐藏的列来实现。这两个列,一个保存了行的创建时间,一个保存了行的过期时间(或删除时间)。当然存储的并不是实际的时间值,而是系统版本号(system version number)。每开始一个新的事务,系统版本号都会自动递增。事务开始时刻的系统版本号会作为事务的版本号,用来查询到的每行记录的版本号进行比较。
Select操作
InnoDB会根据以下两个条件检查每行记录。
-
InnoDB只查找版本号早于当前事务版本的数据行,即行的版本号小于或等于事务的系统的版本号,这可以确保事务读取的行,要么是在事务开始前已经存在的,要么是事务自身插入或者修改过过的。
-
行的删除版本要么未定义,要么大于当前事务版本号,这可以确保事务读取到的行,在事务开始之前未被删除。
INSERT操作
InnoDB为新插入的每一行保存当前系统版本号作为行版本号
DELETE操作
InnoDB为删除的每一行保存当前系统版本号作为行删除标识
UPDATE操作
InnoDB为插入一行新记录,保存当前系统版本号作为行版本号,同时保存当前系统版本号到原来的行作为删除标识(这只是理论,innoDB实际是通过undo log来备份旧记录的)。
MVCC 只在 REPEATABLE READ 和 READ COMMITTED 两个隔离级别下工作。其他两个隔离级别都和MVCC不兼容,因为 READ UNCOMMITTED 总是读取最新的数据行,而不是符合当前事务版本的数据行。SERIALIZABLE则会对所有读取的行都加锁。
快照读和当前读¶
快照读¶
快照读 即:snapshot read,官方叫法是:Consistent Nonlocking Reads,即:一致性非锁定读。只能看到 别的事务生成快照前提交的数据,而不能看到 别的事务生成快照后提交的数据或者未提交的数据。
快照读 是 repeatable-read 和 read-committed 级别下,默认的查询模式,好处是:读不加锁,读写不冲突,这个对于 MySQL 并发访问提升很大。
快照读:读取的是快照版本,也就是历史版本。普通的SELECT就是快照读。就是读取数据的时候会根据一定规则读取事务可见版本的数据(可能是过期的数据),不用加锁
快照读的实现方式:undolog和多版本并发控制MVCC
使用快照读的场景:
- 单纯的select操作,不包括上述 select … lock in share mode、select … for update
Read Committed隔离级别下快照读:每次select都生成一个快照读
Read Repeatable隔离级别下快照读:开启事务后第一个select语句才是快照读的地方,而不是一开启事务就快照读
在 read-committed 隔离级别下,事务中的快照读,总是以最新的快照为基准进行查询的。
在 repeatable-read 隔离级别下,快照读是以事务开始时的快照为基准进行查询的,如果想以最新的快照为基准进行查询,可以先把事务提交完再进行查询。
在 repeatable-read 隔离级别下,别的事务在你生成快照后进行的删除、更新、新增,快照读是看不到的。
当前读¶
当前读:读取的是最新版本, 并且对读取的记录加锁,保证其他事务不会再并发的修改这条记录,避免出现安全问题。
使用当前读的场景:
- select…lock in share mode (共享读锁)
- select…for update
- update
- delete
- insert
当前读的实现方式:next-key锁(行记录锁+Gap间隙锁):
间隙锁:只有在Read Repeatable、Serializable隔离级别才有,就是锁定范围空间的数据,假设id有3,4,5,锁定id>3的数据,是指的4,5及后面的数字都会被锁定,因为此时如果不锁定没有的数据,例如当加入了新的数据id=6,就会出现幻读,间隙锁避免了幻读。
1.对主键或唯一索引,如果当前读时,where条件全部精确命中(=或者in),这种场景本身就不会出现幻读,所以只会加行记录锁。
2.没有索引的列,当前读操作时,会加全表gap锁,生产环境要注意。
3.非唯一索引列,如果where条件部分命中(>、<、like等)或者全未命中,则会加附近Gap间隙锁。例如,某表数据如下,非唯一索引2,6,9,9,11,15。如下语句要操作非唯一索引列9的数据,gap锁将会锁定的列是(6,11],该区间内无法插入数据。
存储引擎层面的锁是为了最大程度的支持并发处理,在InnoDB,锁分行锁、Metadata Lock(事务级表锁),行锁的算法共有三种:Record Lock,Gap Lock,Next-Key Lock
Record Lock:单个行记录的上锁 Gap Lock:间歇锁,不包含记录本身的区间锁 Next-Key Lock:包含记录本身的区间锁 只有在RR隔离级别下才会有gap lock,next-key lock,其中 当where条件为 普通索引时为gap lock或者Next-key Lock 当where条件为 主键索引的时候,Next-key Lock 和Gap Lock的锁策略降级为行锁 当where条件 不是索引的时候,innodb会给所有数据上锁,然后返回Mysql server层,然后在Server层过滤掉不符合条件的数据,通过调用 unlock_row方法解锁
总结¶
- 不可重复读分为2部分:1.快照读 2.当前读
行锁+间隙锁解决了当前读可能会导致的不可重复读的问题mvcc+undo log解决了快照读可能会导致的不可重复读的问题.- mysql的锁和mvcc的设计不单单解决了不可重复读的问题,也解决了幻读的问题
事务日志¶
参考资料¶
索引¶
索引分类¶
聚集索引与非聚集索引¶
以MySQL的InnoDB存储引擎为例,可以有如下解释:
每个索引上包含的字段会有不同,聚集索引包含所有字段,非聚集索引只包含索引字段+主键字段,所以如果在使用非聚集索引后还需要使用其他字段的(包括在where条件中或者select子句中),则需要通过主键索引回表到聚集索引获取其他字段。如果是非聚集索引可以满足SQL语句的所有字段的,则被称为全覆盖索引,没有回表开销。
回表是一个通过主键字段重新查询聚集索引的过程,所以如果在大量记录需要回表的情况下,查询成本会比直接在聚集索引上范围扫描的成本还大。所以对于一些情况,不使用非聚集索引效率反而更高。
InnoDB中主键索引是聚集索引,索引跟数据在一起的。其他索引是非聚集索引,索引指向的是主键索引
为什么要限定是InnoDB存储引擎呢?因为MyISAM存储引擎数据文件和索引文件是分离的,不存在聚集索引的概念。
资料¶
FAQ
FAQ¶
为什么用自增列作为主键?¶
-
如果我们定义了主键(PRIMARY KEY),那么InnoDB会选择主键作为聚集索引。
如果没有显式定义主键,则InnoDB会选择第一个不包含有NULL值的唯一索引作为主键索引。
如果也没有这样的唯一索引,则InnoDB会选择内置6字节长的ROWID作为隐含的聚集索引(ROWID随着行记录的写入而主键递增,这个ROWID不像ORACLE的ROWID那样可引用,是隐含的)。
-
数据记录本身被存于主索引(一颗B+Tree)的叶子节点上,这就要求同一个叶子节点内(大小为一个内存页或磁盘页)的各条数据记录按主键顺序存放
因此每当有一条新的记录插入时,MySQL会根据其主键将其插入适当的节点和位置,如果页面达到装载因子(InnoDB默认为15/16),则开辟一个新的页(节点)
-
如果表使用自增主键,那么每次插入新的记录,记录就会顺序添加到当前索引节点的后续位置,当一页写满,就会自动开辟一个新的页(这样的页称为叶子页)
-
如果使用非自增主键(如果身份证号或学号等),由于每次插入主键的值近似于随机,因此每次新记录都要被插到现有索引页得中间某个位置
此时MySQL不得不为了将新记录插到合适位置而移动数据,甚至目标页面可能已经被回写到磁盘上而从缓存中清掉,此时又要从磁盘上读回来,这增加了很多开销
同时频繁的移动、分页操作造成了大量的碎片,得到了不够紧凑的索引结构,后续不得不通过OPTIMIZE TABLE来重建表并优化填充页面。
查看inodb叶子页大小可以通过:
为什么使用数据索引能提高效率?¶
-
数据索引的存储是有序的
-
在有序的情况下,通过索引查询一个数据是无需遍历索引记录的
-
极端情况下,数据索引的查询效率为二分法查询效率,趋近于 log2(N)
B+树索引和哈希索引的区别¶
B+树是一个平衡的多叉树,从根节点到每个叶子节点的高度差值不超过1,而且同层级的节点间有指针相互链接,是有序的,如下图:
哈希索引就是采用一定的哈希算法,把键值换算成新的哈希值,检索时不需要类似B+树那样从根节点到叶子节点逐级查找,只需一次哈希算法即可,是无序的,如下图所示:
哈希索引的优势与劣势:¶
等值查询,哈希索引具有绝对优势(前提是:没有大量重复键值,如果大量重复键值时,哈希索引的效率很低,因为存在所谓的哈希碰撞问题。)
不支持范围查询
不支持索引完成排序
不支持联合索引的最左前缀匹配规则
通常,B+树索引结构适用于绝大多数场景,像下面这种场景用哈希索引才更有优势:
在HEAP表中,如果存储的数据重复度很低(也就是说基数很大),对该列数据以等值查询为主,没有范围查询、没有排序的时候,特别适合采用哈希索引,例如这种SQL:
B树和B+树的区别¶
B树,每个节点都存储key和data,所有节点组成这棵树,并且叶子节点指针为nul,叶子结点不包含任何关键字信息。
B+树,所有的叶子结点中包含了全部关键字的信息,及指向含有这些关键字记录的指针,且叶子结点本身依关键字的大小自小而大的顺序链接
所有的非终端结点可以看成是索引部分,结点中仅含有其子树根结点中最大(或最小)关键字。 (而B 树的非终节点也包含需要查找的有效信息)
为什么说B+比B树更适合实际应用中操作系统的文件索引和数据库索引?¶
- B+的磁盘读写代价更低。
B+的内部结点并没有指向关键字具体信息的指针,因此其内部结点相对B树更小。
如果把所有同一内部结点的关键字存放在同一盘块中,那么盘块所能容纳的关键字数量也越多。一次性读入内存中的需要查找的关键字也就越多。相对来说IO读写次数也就降低了。
- B+ tree的查询效率更加稳定。
由于非终结点并不是最终指向文件内容的结点,而只是叶子结点中关键字的索引。所以任何关键字的查找必须走一条从根结点到叶子结点的路。所有关键字查询的路径长度相同,导致每一个数据的查询效率相当。
什么情况下应不建或少建索引?¶
-
表记录太少
-
经常插入、删除、修改的表
-
数据重复且分布平均的表字段,假如一个表有10万行记录,有一个字段A只有T和F两种值,且每个值的分布概率大约为50%,那么对这种表A字段建索引一般不会提高数据库的查询速度。
-
经常和主字段一块查询但主字段索引值比较多的表字段
什么是表分区?¶
表分区,是指根据一定规则,将数据库中的一张表分解成多个更小的,容易管理的部分。从逻辑上看,只有一张表,但是底层却是由多个物理分区组成。
表分区与分表的区别¶
分表: 指的是通过一定规则,将一张表分解成多张不同的表。比如将用户订单记录根据时间成多个表。
分表与分区的区别在于: 分区从逻辑上来讲只有一张表,而分表则是将一张表分解成多张表。
表分区有什么好处?¶
-
存储更多数据。分区表的数据可以分布在不同的物理设备上,从而高效地利用多个硬件设备。和单个磁盘或者文件系统相比,可以存储更多数据
-
优化查询。在where语句中包含分区条件时,可以只扫描一个或多个分区表来提高查询效率;涉及sum和count语句时,也可以在多个分区上并行处理,最后汇总结果。
-
分区表更容易维护。例如:想批量删除大量数据可以清除整个分区。
-
避免某些特殊的瓶颈,例如InnoDB的单个索引的互斥访问,ext3问价你系统的inode锁竞争等。
分区表的限制因素¶
-
一个表最多只能有1024个分区
-
MySQL5.1中,分区表达式必须是整数,或者返回整数的表达式。在MySQL5.5中提供了非整数表达式分区的支持。
-
如果分区字段中有主键或者唯一索引的列,那么多有主键列和唯一索引列都必须包含进来。即:分区字段要么不包含主键或者索引列,要么包含全部主键和索引列。
-
分区表中无法使用外键约束
-
MySQL的分区适用于一个表的所有数据和索引,不能只对表数据分区而不对索引分区,也不能只对索引分区而不对表分区,也不能只对表的一部分数据分区。
如何判断当前MySQL是否支持分区?¶
命令:show variables like '%partition%' 运行结果:
have_partintioning 的值为YES,表示支持分区。
MySQL支持的分区类型有哪些?¶
-
RANGE分区: 这种模式允许将数据划分不同范围。例如可以将一个表通过年份划分成若干个分区
-
LIST分区: 这种模式允许系统通过预定义的列表的值来对数据进行分割。按照List中的值分区,与RANGE的区别是,range分区的区间范围值是连续的。
-
HASH分区 :这中模式允许通过对表的一个或多个列的Hash Key进行计算,最后通过这个Hash码不同数值对应的数据区域进行分区。例如可以建立一个对表主键进行分区的表。
-
KEY分区 :上面Hash模式的一种延伸,这里的Hash Key是MySQL系统产生的。
四种隔离级别¶
-
Serializable (串行化):可避免脏读、不可重复读、幻读的发生。
-
Repeatable read (可重复读):可避免脏读、不可重复读的发生。
-
Read committed (读已提交):可避免脏读的发生。
-
Read uncommitted (读未提交):最低级别,任何情况都无法保证。
关于MVVC¶
MySQL InnoDB存储引擎,实现的是基于多版本的并发控制协议——MVCC (Multi-Version Concurrency Control)
注:与MVCC相对的,是基于锁的并发控制,Lock-Based Concurrency Control
MVCC最大的好处:读不加锁,读写不冲突。在读多写少的OLTP应用中,读写不冲突是非常重要的,极大的增加了系统的并发性能,现阶段几乎所有的RDBMS,都支持了MVCC。
-
LBCC:Lock-Based Concurrency Control,基于锁的并发控制
-
MVCC:Multi-Version Concurrency Control
基于多版本的并发控制协议。纯粹基于锁的并发机制并发量低,MVCC是在基于锁的并发控制上的改进,主要是在读操作上提高了并发量。
在MVCC并发控制中,读操作可以分成两类:¶
快照读 (snapshot read):读取的是记录的可见版本 (有可能是历史版本),不用加锁(共享读锁s锁也不加,所以不会阻塞其他事务的写)
当前读 (current read):读取的是记录的最新版本,并且,当前读返回的记录,都会加上锁,保证其他事务不会再并发修改这条记录
行级锁定的优点:¶
1、当在许多线程中访问不同的行时只存在少量锁定冲突。
2、回滚时只有少量的更改
3、可以长时间锁定单一的行。
行级锁定的缺点:¶
比页级或表级锁定占用更多的内存。
当在表的大部分中使用时,比页级或表级锁定速度慢,因为你必须获取更多的锁。
如果你在大部分数据上经常进行GROUP BY操作或者必须经常扫描整个表,比其它锁定明显慢很多。
用高级别锁定,通过支持不同的类型锁定,你也可以很容易地调节应用程序,因为其锁成本小于行级锁定。
MySQL优化¶
-
开启查询缓存,优化查询
-
explain你的select查询,这可以帮你分析你的查询语句或是表结构的性能瓶颈。EXPLAIN 的查询结果还会告诉你你的索引主键被如何利用的,你的数据表是如何被搜索和排序的
-
当只要一行数据时使用limit 1,MySQL数据库引擎会在找到一条数据后停止搜索,而不是继续往后查少下一条符合记录的数据
-
为搜索字段建索引
-
使用 ENUM 而不是 VARCHAR。如果你有一个字段,比如“性别”,“国家”,“民族”,“状态”或“部门”,你知道这些字段的取值是有限而且固定的,那么,你应该使用 ENUM 而不是VARCHAR
-
Prepared StatementsPrepared Statements很像存储过程,是一种运行在后台的SQL语句集合,我们可以从使用 prepared statements 获得很多好处,无论是性能问题还是安全问题。
Prepared Statements 可以检查一些你绑定好的变量,这样可以保护你的程序不会受到“SQL注入式”攻击
-
垂直分表
-
选择正确的存储引擎
Mysql 中 MyISAM 和 InnoDB 的区别有哪些?¶
区别:
-
InnoDB支持事务,MyISAM不支持
-
对于InnoDB每一条SQL语言都默认封装成事务,自动提交,这样会影响速度,所以最好把多条SQL语言放在begin和commit之间,组成一个事务;
-
InnoDB支持外键,而MyISAM不支持。对一个包含外键的InnoDB表转为MYISAM会失败;
-
InnoDB是聚集索引,数据文件是和索引绑在一起的,必须要有主键,通过主键索引效率很高。
但是辅助索引需要两次查询,先查询到主键,然后再通过主键查询到数据。因此主键不应该过大,因为主键太大,其他索引也都会很大。
而MyISAM是非聚集索引,数据文件是分离的,索引保存的是数据文件的指针。主键索引和辅助索引是独立的。
-
InnoDB不保存表的具体行数,执行select count(*) from table时需要全表扫描。而MyISAM用一个变量保存了整个表的行数,执行上述语句时只需要读出该变量即可,速度很快;
-
Innodb不支持全文索引,而MyISAM支持全文索引,查询效率上MyISAM要高;
如何选择:
是否要支持事务,如果要请选择innodb,如果不需要可以考虑MyISAM;
如果表中绝大多数都只是读查询,可以考虑MyISAM,如果既有读写也挺频繁,请使用InnoDB
系统奔溃后,MyISAM恢复起来更困难,能否接受;
MySQL5.5版本开始Innodb已经成为Mysql的默认引擎(之前是MyISAM),说明其优势是有目共睹的,如果你不知道用什么,那就用InnoDB,至少不会差。
查询语句不同元素(where、jion、limit、group by、having等等)执行先后顺序?¶
资料¶
Ended: mysql
Elasticsearch ↵
概览¶
为什么说ES是准实时的?¶
术语¶
| 术语 | 解释 |
|---|---|
| Lucene | Elasticsearch所基于的 Java 库,它引入了按段搜索的概念 |
| Segment | 也叫段,类似于倒排索引,相当于一个数据集 |
| Commit point | 提交点,记录着所有已知的段 |
| Lucene index | “a collection of segments plus a commit point”。由一堆 Segment 的集合加上一个提交点组成 |
对于一个 Lucene index 的组成,如下图所示:
一个 Elasticsearch Index 由一个或者多个 shard (分片) 组成,而 Lucene 中的 Lucene index 相当于 ES 的一个 shard:
写入过程¶
- Document 不断写入到 Indexing buffer,此时也会追加 translog。
- 当 buffer 中的数据每隔
index.refresh_interval秒(或者Indexing buffer满了)缓存refresh到 cache 中时,此时文档是可以检索到了(这也就是为什么说 Elasticsearch 是准实时的)。translog 并没有进入到刷新到磁盘,是持续追加的。 - translog 每隔
index.translog.interval秒会检查translog是否符合flush条件, 如果符合则fsync 到磁盘。
随着translog文件越来越大时要考虑把内存中的数据刷新到磁盘中,这个过程称为flush,flush过程主要做了如下操作:
- 把所有在内存缓冲区中的文档写入到一个新的segment中
- 清空内存缓冲区
- 往磁盘里写入commit point信息
- 文件系统的page cache(segments) fsync到磁盘
- 删除旧的translog文件,因此此时内存中的segments已经写入到磁盘中,就不需要translog来保障数据安全了
ES translog关键配置参数,更多参数见translog参数:
-
index.translog.sync_interval
控制translog多久fsync到磁盘,最小为100ms,默认为5s - index.translog.durability 是每隔
index.translog.sync_interval刷新一次还是每次请求都fsync - request - 每次请求都进行fsync,默认值 - async - 每隔sync_interval都会检查translog大小,判断是否需要fsync - index.translog.flush_threshold_sizetranslog的大小超过这个参数后会flush,默认512mb
删除和更新¶
segment 不可改变,所以 docment 并不能从之前的 segment 中移除或更新。
所以每次 commit, 生成 commit point 时,会有一个 .del 文件,里面会列出被删除的 document(逻辑删除)。
而查询时,获取到的结果在返回前会经过 .del 过滤。更新时,也会标记旧的 docment 被删除,写入到 .del 文件,同时会写入一个新的文件。此时查询会查询到两个版本的数据,但在返回前会被移除掉一个。
segment 合并¶
每 1s 执行一次 refresh 都会将内存中的数据创建一个 segment。 segment 数目太多会带来较大的麻烦。 每一个 segment 都会消耗文件句柄、内存和cpu运行周期。更重要的是,每个搜索请求都必须轮流检查每个 segment ;所以 segment 越多,搜索也就越慢。
在 segment merge 这块,那些被逻辑删除的 document 才会被真正的物理删除。
在 ES 后台会有一个线程进行 segment 合并:
- refresh操作会创建新的 segment 并打开以供搜索使用。
- 合并进程选择一小部分大小相似的 segment,并且在后台将它们合并到更大的 segment 中。这并不会中断索引和搜索。
- 当合并结束,老的 segment 被删除 说明合并完成时的活动:
- 新的 segment 被刷新(flush)到了磁盘。 写入一个包含新 segment 且排除旧的和较小的 segment的新 commit point。
- 新的 segment 被打开用来搜索。
- 老的 segment 被删除。
ElasticSearch 性能调优¶
- https://blog.csdn.net/alex_xfboy/article/details/87938810
- https://hiddenpps.blog.csdn.net/article/details/99145672
- https://blog.csdn.net/laoyang360/article/details/100070285
- https://learnku.com/articles/41631
- https://learnku.com/articles/40845
- https://learnku.com/articles/40099
- https://learnku.com/articles/40844
- https://blog.csdn.net/wmj2004/article/details/80804411
Circuit Breaker¶
ES中包含多种断路器,避免不合理操作引发的 OOM,每个断路器可以指定内存使用的限制 - Parent circuit breaker:设置所有的熔断器可以使用的内存的总量 - Fielddata circuit breaker:加载 fielddata 所需要的内存 - Request circuit breaker:防止每个请求级数据结构超过一定的内存(例如聚合计算的内存) - In flight circuit breaker:Request 中的断路器 - Accounting request circuit breaker:请求结束后不能释放的对象所占用的内存
Circuit Breaker 统计信息:
- Tripped 大于 0, 说明有过熔断 - Limit size 与 estimated size 约接近,越可能引发熔断Search Type¶
查询时候通过设置search_type参数来设置搜索类型。
-
- 向每一个分片发送查询请求
- 在每一个分片上查询符合要求的数据,并且根据当前分片的 TF 和 DF 计算相关性得分
- 构建一个优先级队列存储查询结果(包含分页、排序,等等)
- 把查询结果的 metadata 返回给协调节点。注意真正的文档此时还并没有返回,返回的只是得分数据和对应的文档ID
- 协调节点对从所有分片上返回的得分数据进行归并和排序,根据查询标准对得分数据进行选择
- 最终所有符合查询要求的文档被从其所在的分片上取回到协调节点
- 协调节点将数据返回给客户端
- Dfs, Query Then Fetch
- 预查询所有的分片,得到一个索引中全局的 Term 和 Document 的频率信息
- 向每一个分片发送查询请求,在每一个分片上查询符合要求的数据,并根据全局的 Term 和 Document 的频率信息计算相关性得分
- 构建一个优先级队列存储查询结果(包含分页、排序,等等)
- 把查询结果的 metadata 返回给协调节点。注意,真正的文档此时还并没有返回,返回的只是得分数据
- 协调节点对从所有分片上返回的得分数据进行归并和排序,根据查询标准对得分数据进行选择
- 最终所有符合查询要求的文档被从其所在的分片上取回到协调节点
- 协调节点将数据返回给客户端
深度分页问题¶
我们可以假设在一个有 5 个主分片的索引中搜索。 当我们请求结果的第一页(结果从 1 到 10 ),每一个分片产生前 10 的结果,并且返回给 协调节点 ,协调节点对 50 个结果排序得到全部结果的前 10 个。
现在假设我们请求第 1000 页—结果从 10001 到 10010 。所有都以相同的方式工作除了每个分片不得不产生前10010个结果以外。 然后协调节点对全部 50050 个结果排序最后丢弃掉这些结果中的 50040 个结果。
可以看到,在分布式系统中,对结果排序的成本随分页的深度成指数上升。这就是 web 搜索引擎对任何查询都不要返回超过 1000 个结果的原因。
Doc Values & Field Data¶
- https://learnku.com/articles/38499
- https://blog.csdn.net/thomas0yang/article/details/64905926
- cat api
资料¶
内存占用
Elasticsearch中的内存¶
ES是基于JVM实现的,内存分配分为堆内和堆外两部分。每部分的内存,可以用不同目的的缓存。
堆内存与堆外内存¶
一般情况下,Java中分配的非空对象都是由Java虚拟机的垃圾收集器管理的,称为**堆内内存(on-heap memory)**。虚拟机会定期对垃圾内存进行回收,在某些特定的时间点,它会进行一次彻底的回收(full gc)。彻底回收时,垃圾收集器会对所有分配的堆内内存进行完整的扫描,这意味着一个重要的事实——这样一次垃圾收集对Java应用造成的影响,跟堆的大小是成正比的。过大的堆会影响Java应用的性能。
Java虚拟机的堆以外的内存,即直接收操作系统管理的内存属于**堆外内存(off-heap memory)**,通过把内存对象分配在堆外内存中,可以保持一个较小的堆,可以减少垃圾回收对应用的影响。
ES堆内存¶
我们通过ES启动命名参数选项来设置堆内存大小:
注意事项:
-
堆内存最大值(Xmx)应与对堆内存最小值应该一致,防止程序运行时候会改变堆内存大小,这个很耗系统资源
-
堆内存最大不能超过32GB。因为在Java中,所有对象都分配在堆上并由指针引用。32位的系统,堆内存大小最大为 4 GB。对于64位系统,可通过内存指针压缩(compressed oops)技术,依旧可以使用32位的指针来指向堆对象,这样可以大大节省CPU 内存带宽,提高操作效率。但当内存大小超过32G时候,对象指针就需要变大,操作效率就大大降低。
节点查询缓存¶
节点查询缓存(Node Query Cache)属于node-level缓存,能够被当前节点的所有shard所共享,用于缓存filter的查询结果。Node Query Cache采取LRU内存淘汰策略,当缓存满了,会evicted(驱逐,淘汰)最近最少使用的节点查询缓存。
节点查询缓存的配置重要参数有以下几个:
-
indices.queries.cache.size
用来控制缓存的内存大小,默认是10%,属于节点级别配置。支持百分数,也支持大小精确值。该配置属于静态配置,更改配置需要重启节点 - index.queries.cache.enabled
用来控制具体索引是否启用缓存,默认是开启的。属于index级别配置。只用在索引创建时候或者关闭的索引上设置
-
indices.queries.cache.all_segments
用于控制是否在所有Segment上启用缓存,默认是false,即不会对文档数小于100000或者小于整个索引大小的3%的Segment进行缓存
索引缓冲¶
当文档创建时候,会先保存在索引缓冲(Indexing Buffer)中,然后每隔index.refresh_interval或者索引缓存满的时候进行refresh操作,将文档的段存在系统缓存中,此时文档才能搜索到。
索引缓存的配置重要参数有以下几个:
-
indices.memory.index_buffer_size
用于控制index buffer大小,属于节点级别配置。默认是10%,即允许分配到整个堆大小的10%。支持百分数,也支持大小精确值。 - indices.memory.min_index_buffer_size
用于控制index buffer大小的最小值,当index_buffer_size为百分数时候,才生效。默认是
48mb -
indices.memory.max_index_buffer_size
用于控制index buffer大小的最大值,当index_buffer_size为百分数时候,才生效。默认是**unbounded**(无上限的)
分片请求缓存¶
分片请求缓存(Shard Request Cache)属于shard-level缓存。当进行索引搜索时候,每个相关的shard在本地执行搜索,并将其本地结果返回给协调节点(Coordinating Node),协调节点将这些分片级别的结果合并为一个“全局”结果集。
分片请求缓存只缓存size=0的搜索请求的结果,它不会缓存hits,但会缓存hits.total, aggregations, suggestions。
分片请求缓存的配置重要参数如下:
-
indices.requests.cache.size
设置请求缓存大小,默认是1%。该配置是静态配置。
索引的请求缓存默认是开启的,该配置可以动态开启或关闭:
索引请求缓存的key是根据请求的JSON body得来,当请求的JSON body改变之后,之前缓存的也会失效。另外对于频繁更新的index的,不建议使用该缓存。
我们可以通过以_statAPI查看分片请求缓存大小
GET /_stats/request_cache?human // 查看每个索引的request cache大小
GET /_nodes/stats/indices/request_cache?human // 按节点查看request cache 大小
Fielddata Cache¶
对于Text类型的字段,如果要对其进行聚合和排序,则需要打开字段的Fileddata属性。当对该字段进行聚合,排序时候,ES会把Field Data中加载到内存中,构建成Fielddata Cache,该缓存属于segment-level级别的,整个segment生命周期内都存在。
Fielddata Cache的配置重要参数如下: - indices.fielddata.cache.size
用来控制索引的fileddata cache大小。默认是`unbounded`。支持百分数,也支持大小精确值。该参数是静态配置类型。
大小默认是没有限制的原因是fielddata不是临时性的cache,它能够极大地提升性能,而且构建fielddata又比较耗时的操作,所以需要一直cache。如果没有足够的内存保存fielddata时,Elastisearch会不断地从磁盘加载数据到内存,并剔除掉旧的内存数据。剔除操作会造成严重的磁盘I/O,并且引发大量的GC,会严重影响Elastisearch的性能。
-
indices.breaker.fielddata.limit
用来设置Fielddata断路器限制大小,默认是JVM heap大小的40%。当加载的内存中fielddata数据超过该限制大小,会发生异常,目的为了防止发生JVM OOM。可以动态设置 - indices.breaker.fielddata.overhead
用来设置fielddata过载值,默认值是1.03
我们可以通过以下API查看fielddata大小:
GET /_nodes/stats/indices/fielddata?human
GET /_cat/nodes?v&h=id,ip,port,v,master,name,heap.current,heap.percent,heap.max,ram.current,ram.percent,ram.max,fielddata.memory_size,fielddata.evictions,query_cache.memory_size,query_cache.evictions, request_cache.memory_size,request_cache.evictions,request_cache.hit_count,request_cache.miss_count
GET /_cat/fielddata?v
ES堆外内存¶
在设置ES堆内存时候至少要预留50%物理内存,因为这部分内存主要用做ES堆外内存,堆外内存主要用来来存储Lucene的段
Elasticsearch是基于Lucene实现。Lucene的segments存储在单个文件中,这些文件都是不可变的,文件中包含用于搜索的倒排索引和用于聚合的doc values。为了提高性能,这些文件以系统内存(page cache)的形式常驻常驻内存空间。
我们可以通过cat segments查看索引的segment使用内存的情况:
缓存的清除¶
清除全部的缓存:
清除特定索引的缓存:
清除特定类型缓存:
通过设置fielddata,query,request参数为true来清除特定类型的缓存
POST /my-index/_cache/clear?fielddata=true
POST /my-index/_cache/clear?query=true
POST /my-index/_cache/clear?request=true
参考资料¶
性能调优
Elasticsearch 性能调优¶
从linux参数调优、ES节点配置和ES使用技巧三个角度入手,介绍ES调优的基本方案。
当我们发现es使用还是非常慢,需要优先关注在以下这两类的运行情况:
- hot_threads hot_threads(GET /_nodes/hot_threads&interval=30),抓取30s的节点上占用资源的热线程,并通过排查占用资源最多的TOP线程来判断对应的资源消耗是否正常,一般情况下,bulk,search类的线程占用资源都可能是业务造成的,但是如果是merge线程占用了大量的资源,就应该考虑是不是创建index或者刷磁盘间隔太小,批量写入size太小造成的。
- pending_tasks pending_tasks(GET /_cluster/pending_tasks),有一些任务只能由主节点去处理,比如创建一个新的 索引或者在集群中移动分片,由于一个集群中只能有一个主节点,所以只有这一master节点可以处理集群级别的元数据变动。在99.9999%的时间里,这不会有什么问题,元数据变动的队列基本上保持为零。在一些罕见的集群里,元数据变动的次数比主节点能处理的还快,这会导致等待中的操作会累积成队列。这个时候可以通过pending_tasks api分析当前什么操作阻塞了es的队列,比如,集群异常时,会有大量的shard在recovery,如果集群在大量创建新字段,会出现大量的put_mappings的操作,所以正常情况下,需要禁用动态mapping。
Linux参数调优¶
关闭交换分区¶
防止内存置换降低性能
sed -i '/swap/s/^/#/' /etc/fstab swapoff -a
磁盘挂载选项¶
-
noatime:禁止记录访问时间戳,提高文件系统读写性能
-
data=writeback: 不记录data journal,提高文件系统写入性能
-
barrier=0:barrier保证journal先于data刷到磁盘,上面关闭了journal,这里的barrier也就没必要开启了
-
nobh:关闭buffer_head,防止内核打断大块数据的IO操作
mount -o noatime,data=writeback,barrier=0,nobh /dev/sda /es_data
其他¶
// 修改系统资源限制,单用户可以打开的最大文件数量,可以设置为官方推荐的65536或更大些
echo "* - nofile 655360" >>/etc/security/limits.conf
// 单用户内存地址空间
echo "* - as unlimited" >>/etc/security/limits.conf
// 单用户线程数
echo "* - nproc 2056474" >>/etc/security/limits.conf
// 单用户文件大小
echo "* - fsize unlimited" >>/etc/security/limits.conf
// 单用户锁定内存
echo "* - memlock unlimited" >>/etc/security/limits.conf
// 单进程可以使用的最大map内存区域数量
echo "vm.max_map_count = 655300" >>/etc/sysctl.conf
// TCP全连接队列参数设置, 这样设置的目的是防止节点数较多(比如超过100)的ES集群中,节点异常重启时全连接队列在启动瞬间打满,造成节点hang住,整个集群响应迟滞的情况
echo "net.ipv4.tcp_abort_on_overflow = 1" >>/etc/sysctl.conf
echo "net.core.somaxconn = 2048" >>/etc/sysctl.conf
// 降低tcp alive time,防止无效链接占用链接数
echo 300 >/proc/sys/net/ipv4/tcp_keepalive_time
ES节点配置¶
buffer和bulk队列长度¶
适当增大写入buffer和bulk队列长度,提高写入性能和稳定性
# cat conf/elasticsearch.yml
indices.memory.index_buffer_size: 15%
thread_pool.bulk.queue_size: 1024
新建shard时扫描元数据¶
在规模比较大的集群中,可以防止新建shard时扫描所有shard的元数据,提升shard分配速度。
jvm.options¶
-Xms和-Xmx设置为相同的值,推荐设置为机器内存的一半左右,剩余一半留给系统cache使用。
- jvm内存建议不要低于2G,否则有可能因为内存不足导致ES无法正常启动或OOM
- jvm建议不要超过32G,否则jvm会禁用内存对象指针压缩技术,造成内存浪费
设置内存熔断参数¶
设置内存熔断参数,防止写入或查询压力过高导致OOM,具体数值可根据使用场景调整。
// cat conf/elasticsearch.yml
indices.breaker.total.limit: 30%
indices.breaker.request.limit: 6%
indices.breaker.fielddata.limit: 3%
query cache¶
调小查询使用的cache,避免cache占用过多的jvm内存,具体数值可根据使用场景调整。
ES使用技巧¶
ES底层使用Lucene存储数据,主要包括行存(StoreFiled)、fielddata、列存(DocValues)和倒排索引(InvertIndex)。大多数使用场景中,没有必要同时存储这四个部分。
当前用得最多的就是doc_values,列存储,对于不需要进行分词的字段,都可以开启doc_values来进行存储(且只保留keyword字段),节约内存,当然,开启doc_values会对查询性能有一定的影响,但是,这个性能损耗是比较小的,而且是值得的。
可以通过下面的参数来做适当调整:
StoreFiled¶
行存,其中占比最大的是_source字段,它控制doc原始数据的存储。在写入数据时,ES把doc原始数据的整个json结构体当做一个string,存储为source字段。查询时,可以通过source字段拿到当初写入时的整个json结构体。 所以,如果没有取出整个原始json结构体的需求,可以通过下面的命令,在mapping中关闭source字段或者只在source中存储部分字段,数据查询时仍可通过ES的docvaluefields获取所有字段的值。
**注意:**关闭source后, update, updatebyquery, reindex等接口将无法正常使用,所以有update等需求的index不能关闭source。
// 关闭 _source
PUT my_index
{
"mappings": {
"my_type": {
"_source": {
"enabled": false
}
}
}
}
// _source只存储部分字段,通过includes指定要存储的字段或者通过excludes滤除不需要的字段
PUT my_index
{
"mappings": {
"_doc": {
"_source": {
"includes": [
"*.count",
"meta.*"
],
"excludes": [
"meta.description",
"meta.other.*"
]
}
}
}
}
fielddata¶
构建和管理 100% 在内存中,常驻于 JVM 内存堆,所以可用于快速查询,但是这也意味着它本质上是不可扩展的,有很多边缘情况下要提防,如果对于字段没有分析需求,可以关闭fielddata
docvalues¶
控制列存。ES主要使用列存来支持sorting, aggregations和scripts功能,对于没有上述需求的字段,可以通过下面的命令关闭docvalues,降低存储成本。
PUT my_index
{
"mappings": {
"my_type": {
"properties": {
"session_id": {
"type": "keyword",
"doc_values": false
}
}
}
}
}
index¶
控制倒排索引。ES默认对于所有字段都开启了倒排索引,用于查询。对于没有查询需求的字段,可以通过下面的命令关闭倒排索引。
- all:ES的一个特殊的字段,ES把用户写入json的所有字段值拼接成一个字符串后,做分词,然后保存倒排索引,用于支持整个json的全文检索。这种需求适用的场景较少,可以通过下面的命令将all字段关闭,节约存储成本和cpu开销。(ES 6.0+以上的版本不再支持_all字段,不需要设置)
- fieldnames:该字段用于exists查询,来确认某个doc里面有无一个字段存在。若没有这种需求,可以将其关闭
PUT my_index
{
"mappings": {
"my_type": {
"properties": {
"session_id": {
"type": "keyword",
"index": false
}
}
}
}
}
PUT my_index
{
"mapping": {
"my_type": {
"_all": {
"enabled": false
}
}
}
}
PUT my_index
{
"mapping": {
"my_type": {
"_field_names": {
"enabled": false
}
}
}
}
开启最佳压缩¶
对于_source字段,可以通过下面的命令来把lucene适用的压缩算法替换成 DEFLATE,提高数据压缩率
bulk¶
写入数据时尽量使用下面的bulk接口批量写入,提高写入效率。每个bulk请求的doc数量设定区间推荐为1k~1w,具体可根据业务场景选取一个适当的数量。
调整translog同步策略¶
为了保证不丢数据,translog的持久化策略是,对于每个 index、bulk、delete、update请求都做一次flush(刷新translog数据到磁盘上)。这种频繁的磁盘IO操作是严重影响写入性能的,如果可以接受一定概率的数据丢失(这种硬件故障的概率很小),可以通过下面的命令调整 translog 持久化策略为异步周期性执行,并适当调整translog的刷盘周期。
PUT my_index
{
"settings": {
"index": {
"translog": {
"sync_interval": "5s",
"durability": "async"
}
}
}
}
调整refresh_interval¶
写入Lucene的数据,并不是实时可搜索的,ES必须通过refresh的过程把内存中的数据转换成Lucene的完整segment后,才可以被搜索。
要不要秒级响应?最快1s(index.refresh_interval【默认为一秒】)写入的数据可以被查询到,势必会产生大量的segment,检索性能会受到影响。所以,非实时的场景可以调大,设置为30s,降低系统开销。
merge并发控制¶
ES的一个index由多个shard组成,而一个shard其实就是一个Lucene的index,它又由多个segment组成,且Lucene会不断地把一些小的segment合并成一个大的segment,这个过程被称为段merge。执行索引操作时,ES会先生成小的segment,ES有离线的逻辑对小的segment进行合并,优化查询性能。但是合并过程中会消耗较多磁盘IO,会影响查询性能。
index.merge.scheduler.max_thread_count控制并发的merge线程数,如果存储是并发性能较好的SSD,可以用系统默认的max(1, min(4, availableProcessors / 2)),当节点配置的cpu核数较高时,merge占用的资源可能会偏高,影响集群的性能,普通磁盘的话设为1。可以通过下面的命令调整某个index的merge过程的并发度:
不要指定_id¶
当用户显示指定id写入数据时,ES会先发起查询来确定index中是否已经有相同id的doc存在,若有则先删除原有doc再写入新doc。这样每次写入时,ES都会耗费一定的资源做查询。如果用户写入数据时不指定doc,ES则通过内部算法产生一个随机的id,并且保证id的唯一性,这样就可以跳过前面查询id的步骤,提高写入效率。所以,在不需要通过id字段去重、update的使用场景中,写入不指定id可以提升写入速率。基础架构部数据库团队的测试结果显示,无id的数据写入性能可能比有_id的高出近一倍,实际损耗和具体测试场景相关。
使用routing¶
对于数据量较大的index,一般会配置多个shard来分摊压力。这种场景下,一个查询会同时搜索所有的shard,然后再将各个shard的结果合并后,返回给用户。对于高并发的小查询场景,每个分片通常仅抓取极少量数据,此时查询过程中的调度开销远大于实际读取数据的开销,且查询速度取决于最慢的一个分片。开启routing功能后,ES会将routing相同的数据写入到同一个分片中(也可以是多个,由index.routingpartitionsize参数控制)。如果查询时指定routing,那么ES只会查询routing指向的那个分片,可显著降低调度开销,提升查询效率。
// 写入
PUT my_index/my_type/1?routing=user1
{
"title": "This is a document"
}
//查询
GET my_index/_search?routing=user1,user2
{
"query": {
"match": {
"title": "document"
}
}
}
text or keyword¶
为string类型的字段选取合适的存储方式,text或者keywork类型。
使用query-bool-filter组合取代普通query¶
默认情况下,ES通过一定的算法计算返回的每条数据与查询语句的相关度,并通过score字段来表征。但对于非全文索引的使用场景,用户并不care查询结果与查询条件的相关度,只是想精确的查找目标数据。此时,可以通过query-bool-filter组合来让ES不计算score,并且尽可能的缓存filter的结果集,供后续包含相同filter的查询使用,提高查询效率。
// 普通查询
POST my_index/_search
{
"query": {
"term": {
"user": "Kimchy"
}
}
}
// query-bool-filter 加速查询
POST my_index/_search
{
"query": {
"bool": {
"filter": {
"term": {
"user": "Kimchy"
}
}
}
}
}
index按日期滚动存储¶
写入ES的数据最好通过某种方式做分割,存入不同的index。常见的做法是将数据按模块/功能分类,写入不同的index,然后按照时间去滚动生成index。这样做的好处是各种数据分开管理不会混淆,也易于提高查询效率。同时index按时间滚动,数据过期时删除整个index,要比一条条删除数据或deletebyquery效率高很多,因为删除整个index是直接删除底层文件,而deletebyquery是查询-标记-删除。
// module_a
PUT module_a@2018_01_01
{
"settings" : {
"index" : {
"number_of_shards" : 3,
"number_of_replicas" : 2
}
}
}
PUT module_a@2018_01_02
{
"settings" : {
"index" : {
"number_of_shards" : 3,
"number_of_replicas" : 2
}
}
}
GET module_a@*/_search
// module_b
PUT module_b@2018_01_01
{
"settings" : {
"index" : {
"number_of_shards" : 3,
"number_of_replicas" : 2
}
}
}
PUT module_b@2018_01_02
{
"settings" : {
"index" : {
"number_of_shards" : 3,
"number_of_replicas" : 2
}
}
}
GET module_b@*/_search
分片数和副本数按需控制¶
对于每个index的shard数量,可以根据数据总量、写入压力、节点数量等综合考量后设定,然后根据数据增长状态定期检测下shard数量是否合理。多少合适?
Segment Memory优化¶
ES底层采用Lucene做存储,而Lucene的一个index又由若干segment组成,每个segment都会建立自己的倒排索引用于数据查询。Lucene为了加速查询,为每个segment的倒排做了一层前缀索引,这个索引在Lucene4.0以后采用的数据结构是FST (Finite State Transducer)。Lucene加载segment的时候将其全量装载到内存中,加快查询速度。这部分内存被称为SegmentMemory, 常驻内存,占用heap,无法被GC。前面提到,为利用JVM的对象指针压缩技术来节约内存,通常建议JVM内存分配不要超过32G。当集群的数据量过大时,SegmentMemory会吃掉大量的堆内存,而JVM内存空间又有限,此时就需要想办法降低SegmentMemory的使用量了,常用方法有下面几个:
- 定期删除不使用的index
- 对于不常访问的index,可以通过close接口将其关闭,用到时再打开
- 通过force_merge接口强制合并segment,降低segment数量
禁止动态mapping¶
动态mapping的坏处:
- 造成集群元数据一直变更,导致集群不稳定
- 可能造成数据类型与实际类型不一致
- 对于一些异常字段或者是扫描类的字段,也会频繁的修改mapping,导致业务不可控
参考资料¶
生产配置参考
Elasticsearch在生产环境部署时候,我们需要考虑系统配置优化和Es本身配置优化,已达到能够发挥其最佳性能。本文是根据官方文档和个人工作实践总结出的生产环境配置。
生产环境配置¶
系统配置¶
Elasticsearch应该完全占用服务器资源,尽量不要再部署其他服务。系统配置方面应该考虑一下几个方面:
- 禁用内存交换(Disable swapping)
- 增加文件描述符(Increase file descriptors)
- 确保有足够的虚拟内存(Ensure sufficient virtual memory)
- 确保足够的线程(Ensure sufficient threads)
禁止内存交换¶
内存交换到磁盘对性能,节点稳定性非常不利,应不惜一切代价避免交换。它可能导致垃圾收集持续数分钟而不是毫秒,并且可能导致节点响应缓慢甚至断开与群集的连接。
1. 使用swapoff禁止所有的交换
通常Elasticsearch是在系统上运行的唯一服务,其内存使用量由JVM选项控制。无需启用交换功能。
在Linux系统上,可以通过运行以下命令暂时禁用交换:
要永久禁用它,需要编辑/etc/fstab文件并注释掉包含单词swap的所有行。
2. 配置swappiness允许紧急情况使用内存交换
在Linux系统上可用的**另一种选择是确保将sysctl值vm.swappiness设置为1。这可以减少内核的交换趋势,并且在正常情况下不应导致交换,同时仍然允许整个系统在紧急情况下进行交换**。vm.swappiness参数可以在机器使用内存、交互分区的比例进行调整,起到优化作用
-
vm.swappiness的值在0-100之间,当为0表示最大限度只用物理内存,而后使用swap空间;当swappiness为100时表示最大限度使用swap空间,把内存中的数据及时搬运到swap空间中去
-
当内存使用到(100-vm.swappiness)%时,就会开始出现交换分区的使用了。
配置swappingess:
- 查看当前设置的vm.swappiness值
- 临时调整,会在机器重启后恢复原先设置的值
- 永久调整
3. 配置bootstrap.memory_lock禁止ES内存交换
在类Unix系统上使用mlockall或在Windows上使用VirtualLock尝试将进程地址空间锁定在RAM中,以防止任何Elasticsearch内存被换出。可以通过将以下行添加到config / elasticsearch.yml文件中来完成此操作:
注意: 如果mlockall尝试分配的内存超过可用内存,则可能导致JVM退出!
启动Elasticsearch之后,可以通过检查此请求的输出中的mlockall值来查看是否成功应用了此设置:
如果看到mlockall为false,则表示mlockall请求已失败,将在日志中看到一行包含更多信息的行,内容为“无法锁定JVM内存”。内存锁定失败可以查看系统配置-无法锁定JVM内存
增加文件描述符数量¶
Elasticsearch在使用过程中会打开许多文件描述符,请确保将运行Elasticsearch的用户最大打开文件描述符的数量的限制提高到65536或更高。
下面增加文件描述符数量:
或者在/etc/security/limits.conf中将nofile设置为65535:
启动Elasticsearch之后,通过以下方式检查是否设置成功:
确保有足够的虚拟内存¶
Elasticsearch默认使用mmapfs目录存储其索引。默认的操作系统对mmap计数的限制可能太低,这可能会导致内存不足异常。
永久性配置:
确保足够的线程¶
Elasticsearch对不同类型的操作使用许多线程池。需要确保Elasticsearch用户可以创建的线程数至少为4096。
可以通过在启动Elasticsearch之前执行sudo ulimit -u 4096,重启系统会失效
或者永久性更改
Elasticsearch配置¶
最少候选主节点配置¶
为了防止数据丢失,至关重要的是配置discovery.zen.minimum_master_nodes,该配置是为了使用每个候选主节点(master eligible node)都知道形成ES集群最少节点数量。最少候选节点节点数在分布式系统中称为Quorum(法定人数)。
设置Quorum是为了防止网络故障时候,ES群集会发生脑裂,这会造成数据丢失。为避免大脑分裂,应将discovery.zen.minimum_master_nodes设置候选主节点数的一半加1:
对于较小ES集群,可以选择3或者5个候选主节点。下表是分布式服务节点Quorum数量参考:
| Servers | Quorum Size | Failure Tolerance |
|---|---|---|
| 1 | 1 | 0 |
| 2 | 2 | 0 |
| 3 | 2 | 1 |
| 4 | 3 | 1 |
| 5 | 3 | 2 |
| 6 | 4 | 2 |
| 7 | 4 | 3 |
关于脑裂更多知识参见Avoiding split brain with minimum_master_nodes
设置合理堆大小¶
默认情况下,Elasticsearch的JVM使用最小和最大大小为1 GB的堆。生产换中需确保Elasticsearch有足够的可用堆。
Elasticsearch将通过Xms(最小堆大小)和Xmx(最大堆大小)设置分配jvm.options中指定的整个堆。
设置堆大小应该遵循以下原则:
- 将最小堆大小(Xms)和最大堆大小(Xmx)设置一样大小
- Xmx设置不应超过物理RAM的50%,以确保有足够的内核文件缓存来存储lucene的segment。
- 请勿将Xmx设置为高于JVM用于压缩对象指针的临界值,即不要超过32G
可以通过在日志中查找如下一行来验证压缩对象指针是否处于限制范围内:
heap size [1.9gb], compressed ordinary object pointers [true]
我们通过如下设置堆大小:
或者通过环境变量设置:
禁止自动创建索引¶
当向一个不存在的索引中写入文档时,会自动创建索引。为了更好控制索引的创建,推荐在生产环境配置禁止自动创建索引
对于日志类等基于时间序列的索引,可能需要允许自动创建索引,我们可以设置白名单
禁止通过通配符或_all删除索引¶
为了安全,应该禁止通过通配符号或_all删除索引
防止监控日志索引占用过大空间¶
xpack支持ES和kibana的监控,默认保存15天的监控日志。若磁盘空间有限,可以考虑调整监控索引(.monitoring-es-*、 .monitoring-kibana-*等)保留天数
开启慢日志¶
开启慢日志的目的是捕获那些超过指定时间阈值的查询和索引请求,以便针对性优化。慢日志默认是不开启的。慢日志是shard-level的,分为搜索慢日志和索引慢日志。我们可以针对特定索引开启慢日志,也可针对整个集群设置慢日志,所有索引都会继承集群慢日志设置。
慢日志设置时候需要定义日志类型(search和indexing),日志记录级别,以及时间阈值。
针对特定索引开启搜索慢日志:
/my_index/_settings
{
"index.search.slowlog.threshold.query.warn": "10s",
"index.search.slowlog.threshold.query.info": "5s",
"index.search.slowlog.threshold.query.debug": "2s",
"index.search.slowlog.threshold.query.trace": "500ms",
"index.search.slowlog.threshold.fetch.warn": "1s",
"index.search.slowlog.threshold.fetch.info": "800ms",
"index.search.slowlog.threshold.fetch.debug": "500ms",
"index.search.slowlog.threshold.fetch.trace": "200ms",
"index.search.slowlog.level": "info" // 只记录info和info以上的慢日志
}
针对特定索引开启索引慢日志:
PUT /my_index/_settings
{
"index.indexing.slowlog.threshold.index.warn": "10s",
"index.indexing.slowlog.threshold.index.info": "5s",
"index.indexing.slowlog.threshold.index.debug": "2s",
"index.indexing.slowlog.threshold.index.trace": "500ms",
"index.indexing.slowlog.level": "info",
"index.indexing.slowlog.source": "1000" // 只记录前1000个字符
}
针对整个集群设置:
PUT /_cluster/settings
{
"transient": {
"logger.index.search.slowlog": "DEBUG",
"logger.index.indexing.slowlog": "DEBUG"
}
}
禁止自动创建mapping¶
默认情况下,ES会自动创建推断文档的field类型,并自动创建mapping,有时候设置字段类型并不是最合适的,我们需要禁止自动创建mapping。
PUT my_index
{
"mappings": {
"_doc": {
"dynamic": false, // 禁止dynamic mapping
"properties": {
"user": {
"properties": {
"name": {
"type": "text"
},
"social_networks": {
"dynamic": true,
"properties": {}
}
}
}
}
}
}
}
dynamic设置一共有3个值可选,默认是true,我们需要设置false或者strict即可。
| 值 | 功能 |
|---|---|
| true | 自动侦测新字段,并加到mapping中。默认值 |
| false | 新字段不会被索引,但会随着_source字段返回 |
| strict | 若新字段没有添加到mapping中,则创建文档时候报错 |
参考资料¶
doc values vs filed data¶
doc values简介¶
当你对一个字段进行排序时,Elasticsearch 需要访问每个匹配到的文档得到相关的值。倒排索引的检索性能是非常快的,但是在字段值排序时却不是理想的结构。当排序的时候,我们需要倒排索引里面某个字段值的集合。换句话说,我们需要转置倒排索引。转置结构在其他系统中经常被称作列存储。实质上,它将所有单字段的值存储在单数据列中,这使得对其进行排序等操作是十分高效的。
在 Elasticsearch 中,Doc Values 就是一种列式存储结构。Doc Values 是在索引时创建的,当字段索引时,Elasticsearch 为了能够快速检索,会把字段的值加入倒排索引中,同时它也会存储该字段的 Doc Values。
Elasticsearch 中的 Doc Values 常被应用到以下场景:
- 对一个字段进行排序
- 对一个字段进行聚合
- 某些过滤,比如地理位置过滤
- 某些与字段相关的脚本计算
- 父子关系处理
因为**文档值被序列化到磁盘**,我们可以依靠操作系统的帮助来快速访问。当 working set 远小于节点的可用内存,系统会自动将所有的文档值保存在内存中,使得其读写十分高速; 当其远大于可用内存,操作系统会自动把 Doc Values 加载到系统的页缓存中,从而避免了jvm堆内存溢出异常。
Doc values原理¶
倒排索引的优势 在于查找包含某个项的文档,而对于从另外一个方向的相反操作并不高效,即:确定哪些项是否存在单个文档里,聚合需要这种次级的访问模式。
对于以下倒排索引:
Term Doc_1 Doc_2 Doc_3
------------------------------------
brown | X | X |
dog | X | | X
dogs | | X | X
fox | X | | X
foxes | | X |
in | | X |
jumped | X | | X
lazy | X | X |
leap | | X |
over | X | X | X
quick | X | X | X
summer | | X |
the | X | | X
------------------------------------
如果我们想要获得所有包含 brown 的文档的词的完整列表,我们会创建如下查询:
GET /my_index/_search
{
"query" : {
"match" : {
"body" : "brown"
}
},
"aggs" : {
"popular_terms": {
"terms" : {
"field" : "body"
}
}
}
}
查询部分简单又高效。倒排索引是根据项来排序的,所以我们首先在词项列表中找到 brown ,然后扫描所有列,找到包含 brown 的文档。我们可以快速看到 Doc_1 和 Doc_2 包含 brown 这个 token。
然后,对于聚合部分,我们需要找到 Doc_1 和 Doc_2 里所有唯一的词项。 用倒排索引做这件事情代价很高: 我们会迭代索引里的每个词项并收集 Doc_1 和 Doc_2 列里面 token。这很慢而且难以扩展:随着词项和文档的数量增加,执行时间也会增加。
Doc values 通过转置两者间的关系来解决这个问题。倒排索引将词项映射到包含它们的文档,doc values 将文档映射到它们包含的词项:
Doc Terms
-----------------------------------------------------------------
Doc_1 | brown, dog, fox, jumped, lazy, over, quick, the
Doc_2 | brown, dogs, foxes, in, lazy, leap, over, quick, summer
Doc_3 | dog, dogs, fox, jumped, over, quick, the
-----------------------------------------------------------------
当数据被转置之后,想要收集到 Doc_1 和 Doc_2 的唯一 token 会非常容易。获得每个文档行,获取所有的词项,然后求两个集合的并集。
因此,搜索和聚合是相互紧密缠绕的。搜索使用倒排索引查找文档,聚合操作收集和聚合 doc values 里的数据。
Doc values深入理解¶
Doc Values 是在索引时与 倒排索引 同时生成。也就是说 Doc Values 和 倒排索引 一样,基于 Segement 生成并且是不可变的。同时 Doc Values 和 倒排索引 一样序列化到磁盘,这样对性能和扩展性有很大帮助。
Doc Values 通过序列化把数据结构持久化到磁盘,我们可以充分利用操作系统的内存,而不是 JVM 的 Heap(Doc Values 不是由 JVM 来管理)。 当 working set 远小于系统的可用内存,系统会自动将 Doc Values 驻留在内存中,使得其读写十分快速;不过,当其远大于可用内存时,系统会根据需要从磁盘读取 Doc Values,然后选择性放到分页缓存中。很显然,这样性能会比在内存中差很多,但是它的大小就不再局限于服务器的内存了。
列式存储的压缩编辑¶
从广义来说,Doc Values 本质上是一个序列化的列式存储。 正如我们上一节所讨论的,列式存储 适用于聚合、排序、脚本等操作。
而且,这种存储方式也非常便于压缩,特别是数字类型。这样可以减少磁盘空间并且提高访问速度。现代 CPU 的处理速度要比磁盘快几个数量级(尽管即将到来的 NVMe 驱动器正在迅速缩小差距)。所以我们必须减少直接存磁盘读取数据的大小,尽管需要额外消耗 CPU 运算用来进行解压。
要了解它如何压缩数据的,来看一组数字类型的 Doc Values:
Doc Terms
-----------------------------------------------------------------
Doc_1 | 100
Doc_2 | 1000
Doc_3 | 1500
Doc_4 | 1200
Doc_5 | 300
Doc_6 | 1900
Doc_7 | 4200
-----------------------------------------------------------------
按列布局意味着我们有一个连续的数据块: [100,1000,1500,1200,300,1900,4200] 。因为我们已经知道他们都是数字(而不是像文档或行中看到的异构集合),所以我们可以使用统一的偏移来将他们紧紧排列。
而且,针对这样的数字有很多种压缩技巧。你会注意到这里每个数字都是 100 的倍数,Doc Values 会检测一个段里面的所有数值,并使用一个 最大公约数 ,方便做进一步的数据压缩。
如果我们保存 100 作为此段的除数,我们可以对每个数字都除以 100,然后得到: [1,10,15,12,3,19,42] 。现在这些数字变小了,只需要很少的位就可以存储下,也减少了磁盘存放的大小。
Doc Values 在压缩过程中使用如下技巧。它会按依次检测以下压缩模式:
如果所有的数值各不相同(或缺失),设置一个标记并记录这些值 如果这些值小于 256,将使用一个简单的编码表 如果这些值大于 256,检测是否存在一个最大公约数 如果没有存在最大公约数,从最小的数值开始,统一计算偏移量进行编码 你会发现这些压缩模式不是传统的通用的压缩方式,比如 DEFLATE 或是 LZ4。 因为列式存储的结构是严格且良好定义的,我们可以通过使用专门的模式来达到比通用压缩算法(如 LZ4 )更高的压缩效果。
提示:你也许会想 "好吧,貌似对数字很好,不知道字符串怎么样?" 通过借助顺序表(ordinal table),String 类型也是类似进行编码的。String 类型是去重之后存放到顺序表的,通过分配一个 ID,然后通过数字类型的 ID 构建 Doc Values。这样 String 类型和数值类型可以达到同样的压缩效果。顺序表本身也有很多压缩技巧,比如固定长度、变长或是前缀字符编码等等
Fielddata¶
doc values 不生成分析的字符串,然而,这些字段仍然可以使用聚合,是因为使用了fielddata 的数据结构。与 doc values 不同,fielddata 构建和管理 100% 在内存中,常驻于 JVM 内存堆。fielddata 是 所有 字段的默认设置。
Fielddata预加载¶
加载fielddata默认是延迟加载 。 当 Elasticsearch 第一次查询某个字段时,它将会完整加载这个字段所有 Segment中的倒排索引到内存中,以便于以后的查询能够获取更好的性能。
对于小索引段来说,这个过程的需要的时间可以忽略。但如果索引很大几个GB,这个过程可能会要数秒。对于 已经习惯亚秒响应的用户很难会接受停顿数秒卡顿。
有下面两种方式可以解决这个延时高峰:
- 预加载 fielddata,设置提前加载。
- 预加载全局序号。一种减少内存占用的加载优化方式,类似于一种全局字典(存储string字段和其对应的全局唯一int值),这样只加载int值,然后查找字典中的对应的string字段。
资料¶
Ended: Elasticsearch
redis¶
redis架构¶
- 纯内存操作
- 单线程避免了多线程因为同步导致的频繁的上下文切换问题
- 高效的数据结构
- 核心是基于非阻塞的IO多路复用(epoll)
应用场景¶
- 缓存
- 队列
- 排行榜
- 自动补全
- 分布式锁
- UV统计(hyperloglog)
- 去重过滤(基于布隆过滤器)
- 限流器
- 用户签到/用户在线状态/活跃用户统计(基于位图)
过期策略¶
-
惰性删除
- 当程序访问某key时候,会检查key是否过期,过期则删除
-
定期删除
- 从过期key字典中随机 20 个 key
- 删除这 20 个 key 中已经过期的 key
- 如果过期的 key 比率超过 ¼,那就重复步骤 1
- 扫描处理时间默认不会超过 25ms
最大内存策略¶
当内存使用达到设置的最大内存上限(配置参数 maxmemory )
- noeviction 一直写,直到可用内存使用完,写不进数据
- volatile
- volatile-ttl 设置了过期时间,ttl时间越短的key越优先被淘汰
- volatile-lru 基于LRU规则淘汰删除设置了过期时间的key
- volatile-random 随机淘汰过期集合中的key
- allkeys
- allkeys-lru 基于LRU规则淘汰所有key
- allkeys-random 随机淘汰
持久化方式¶
一句话:使用RDB持久化会有数据丢失的风险,但是恢复速度快,而使用AOF持久化可以保证数据完整性,但恢复数据的时候会很慢
RDB¶
RDB就是Snapshot快照存储,是默认的持久化方式。可理解为半持久化模式,即按照一定的策略周期性的将数据保存到磁盘。对应产生的数据文件为dump.rdb,通过配置文件中的save参数来定义快照的周期。下面是默认的快照设置:
# RDB持久化策略 默认三种方式,[900秒内有1次修改],[300秒内有10次修改],[60秒内有10000次修改]即触发RDB持久化,我们可以手动修改该参数或新增策略
save 900 1
save 300 10
save 60 10000
#RDB文件名
dbfilename "dump.rdb"
#RDB文件存储路径
dir "/opt/app/redis6/data"
优点:
- 压缩后的二进制文件,非常适合备份
- 非常适用于灾难恢复
- 存储性能高:存储数据时,父进程fork出一个子进程,父进程无需执行任何磁盘IO操作
不足:
- 一旦数据库出现问题,那么dump.rdb文件中保存的数据并不是全新的。从上次RDB文件生成到Redis停机这段时间的数据全部丢掉了。
- 当备份的数据集比较大时,可能会非常耗时,造成服务器停止处理客户端请求;
AOF¶
AOF(Append-Only File)比RDB方式有更好的持久化性。由于在使用AOF持久化方式时,Redis会将每一个收到的写命令都通过Write函数追加到文件中(默认appendonly.aof),类似于MySQL的binlog。当Redis重启时会通过重新执行文件中保存的写命令来在内存中重建整个数据库的内容。
#开启AOF持久化
appendonly yes
#AOF文件名
appendfilename "appendonly.aof"
#AOF文件存储路径 与RDB是同一个参数
dir "/opt/app/redis6/data"
#AOF策略,一般都是选择第一种[always:每个命令都记录],[everysec:每秒记录一次],[no:看机器的心情高兴了就记录]
appendfsync always
#appendfsync everysec
# appendfsync no
#aof文件大小比起上次重写时的大小,增长100%(配置可以大于100%)时,触发重写。[假如上次重写后大小为10MB,当AOF文件达到20MB时也会再次触发重写,以此类推]
auto-aof-rewrite-percentage 100
#aof文件大小超过64MB时,触发重写
auto-aof-rewrite-min-size 64mb
为了避免AOF文件中的写命令太多文件太大,Redis引入了AOF的重写机制来压缩AOF文件体积。AOF文件重写是把Redis进程内的数据转化为写命令同步到新AOF文件的过程。重写会根据重写策略或手动触发AOF重写。
优点:
- 以文本形式保存,易读
- 记录写操作保证数据不丢失
缺点:
- 存储所有写操作命令,且文件为文本格式保存,未经压缩,文件体积高
- 恢复数据时重放AOF中所有代码,恢复性能弱于RDB方式
AOF+RDB混合¶
开启混合模式后,每当bgrewriteaof命令之后会在AOF文件中以RDB格式写入当前最新的数据,之后的新的写操作继续以AOF的追加形式追加写命令。当redis重启的时候,加载 aof 文件进行恢复数据:先加载 rdb 的部分再加载剩余的 aof部分。
修改下面的参数即可开启AOF,RDB混合持久化:
Redis重启时加载持久化文件的顺序¶
Redis重启的时候优先加载AOF文件,如果AOF文件不存在再去加载RDB文件。 如果AOF文件和RDB文件都不存在,那么直接启动。 不论加载AOF文件还是RDB文件,只要发生错误都会打印错误信息,并且启动失败。
如何选择?¶
- 通常,如果你要想提供很高的数据保障性,那么建议你同时使用两种持久化方式。
- 如果你可以接受灾难带来的几分钟的数据丢失,那么你可以仅使用RDB。
- 很多用户仅使用了AOF,但是我们建议,既然RDB可以时不时的给数据做个完整的快照,并且提供更快的重启,所以最好还是也使用RDB。
- 因此,我们希望可以在未来(长远计划)统一AOF和RDB成一种持久化模式。
复杂度¶
键¶
整个Redis 数据库的所有key 和value 也组成了一个全局字典,还有带过期时间的key 集合也是一个字典。
typedef struct redisDb {
dict *dict;
// all keys, key => value。所有的key和对应的value
dict *expires; // all expire keys, key => long(timestamp),所有设置过期时间的key,和对应过期时间
} redisDb;
### zset复杂度
Redis 的zset 是一个复合结构,一方面它需要一个hash结构来存储value(成员) 和score 的对应关系,
另一方面需要提供按照score 来排序的功能,还需要能够指定score 的范围来获取value 列表的功能,这个时候通过跳跃列表实现。
```c++
typedef struct zset {
dict *dict; // 字典
zskiplist *zsl; // 跳表
} zset;
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length;
int level;
} zskiplist;
typedef struct zskiplistNode {
sds ele;
double score;
struct zskiplistNode *backward;
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned long span;
} level[];
} zskiplistNode;
Zrank的复杂度是O(log(n)),为什么?
Redis 在skiplist 的forward 指针上进行了优化,给每一个forward 指针都增加了span 属性,span 是「跨度」的意思, 表示从前一个节点沿着当前层的forward 指针跳到当前这个节点中间会跳过多少个节点 这样计算一个元素的排名时,只需要将「搜索路径」上的经过的所有节点的跨度span 值进行叠加就可以算出元素的最终rank 值。
zrange 的复杂度是 O(log(N)+M), N 为有序集的基数,而 M 为结果集的基数。为什么是这个复杂度呢?
ZRANGE key start stop [WITHSCORES],zrange 就是返回有序集 key 中,指定区间内的成员,而跳表中的元素最下面的一层是有序的(上面的几层就是跳表的索引),按照分数排序,我们只要找出 start 代表的元素,然后向前或者向后遍历 M 次拉出所有数据即可,而找出 start 代表的元素,其实就是在跳表中找一个元素的时间复杂度。跳表中每个节点每一层都会保存到下一个节点的跨度,在寻找过程中可以根据跨度和来求当前的排名,所以查找过程是 O(log(N) 过程,加上遍历 M 个元素,就是 O(log(N)+M),所以 redis 的 zrange 不会像 mysql 的 offset 有比较严重的性能问题。
时间复杂度O(n)¶
List:
Hash:
Set:
smembers // 返回所有集合成员,n为集合成员元素
sunion/sunionstore // 并集, N 是所有给定集合的成员数量之和
sinter/sinterstore // 交集,O(N * M), N 为给定集合当中基数最小的集合, M 为给定集合的个数
sdiff/sdiffstore // 差集, N 是所有给定集合的成员数量之和
Sorted Set:
zrange/zrevrange/zrangebyscore/zrevrangebyscore/zremrangebyrank/zremrangebyscore // O(m) + O(log(n)) // N 为有序集的基数,而 M 为结果集的基数
生产严禁使用的命令:
我们可以在生产环境通过设置空别名来禁止危险命令:
统计概览¶
基于Redis实现分布式锁的几种方案¶
基于Redis实现分布式锁的缺点:
-
超时时间不好设置
当线程A获取到锁之后,可能业务还没有执行完成,锁就过期了
-
锁可能无法永远无法释放
-
锁可靠性问题
-
对于redis cluster集群,当线程A刚获取到锁后,此时锁所在Master节点恰好挂了,数据还没同步到Slave节点,而Slave节点恰好升级为主节点,导致B线程可以获取到锁。此时A、B线程同时在执行业务
-
线程A执行完成任务后,去释放锁,可能是否释放掉了其他线程持有的锁(比如线程A执行完成时候,锁早已过期,线程B获取到了锁)
-
SETNX + EXPIRE¶
如果执行完setnx加锁,正要执行expire设置过期时间时,进程crash或redis挂掉了,会到锁永远无法释放。
SETNX + value值是(系统时间+过期时间)¶
可以把过期时间放到setnx的value值里面。如果加锁成功,再拿出value值校验一下是否过期即可。
使用Lua脚本(包含SETNX + EXPIRE两条指令)¶
SET的扩展命令(SET EX PX NX)¶
SET key value[EX seconds][PX milliseconds][NX|XX]
- NX :表示key不存在的时候,才能set成功,也即保证只有第一个客户端请求才能获得锁,而其他客户端请求只能等其释放锁,才能获取。
- EX seconds :设定key的过期时间,时间单位是秒。
- PX milliseconds: 设定key的过期时间,单位为毫秒
- XX: 仅当key存在时设置值
XX可以设置当前线程关联的值,当要释放锁时候,判断当前所锁的关联的值是否是当前线程关联的值,如果是才允许释放,这解决了3.b问题。可以通过Lua脚本保证这个过程的原子性:
Redisson¶
给获得锁的线程,开启一个定时守护线程,每隔一段时间检查锁是否还存在,存在则对锁的过期时间延长,防止锁过期提前释放。这解决了问题1。
当前开源框架Redisson解决了这个问题。Redisson底层原理如下:
Redlock¶
对于3.a问题,Redis作者 antirez提出一种高级的分布式锁算法:Redlock。Redlock核心思想如下:
当一个客户端要获取红锁时,它会尝试在多个 Redis 节点上分别执行 SETNX(SET if Not eXists)命令,如果大多数(N/2+1)加锁成功了,则认为获取锁成功。
资料¶
Ended: 数据库
开发语言 ↵
Go
如何评价Go语言?¶
- 简洁
- 语法简洁,没有传统语言的继承,try-catch异常处理机制
- 并发编程模式简单,通过通道控制
- 但支持类型断言,泛型(1.18开始)
- 并发
- 采用混合调度模型
- 采用通道进行数据同步
- 内存安全
- 自带垃圾回收功能
- 良好的工具生态
- 自带格式化工具
- 内置性能调优诊断工具
Go的调度机制(GMP模型)¶
GMP指的是什么?¶
- G(Goroutine):Goroutine,即协程,为用户级的轻量级线程,每个 Goroutine对象中的 sched 保存着其上下文信息(sp、pc等信息)。G是参与调度与执行的最小单位,是并发的关键。
- M(Machine):是对内核级线程的抽象封装。M负责执行G。
- P(Processor):即为 G 和 M 的调度对象,用来调度 G 和 M 之间的关联关系,其数量可通过 GOMAXPROCS()或者GOMAXPROC环境变量来设置,默认为核心数。Linux中P的数量是通过CPU亲和性的系统调用获取。每个P都拥有一个本地可运行G的队列(Local ruanble queue,简称为LRQ),该队列最多可存放256个G。P的runnext字段也存放了一个G,属于快速路径。
GMP调度流程¶
- 每个P有个局部队列(LRQ),局部队列保存待执行的goroutine(流程2),当M绑定的P的的局部队列已经满了之后就会把goroutine放到全局队列(流程2-1)
- 每个P和一个M绑定,M是真正的执行P中goroutine的实体(流程3) ,M从绑定的P中的局部队列获取G来执行
- 当M绑定的P的局部队列为空时,M会从全局队列获取到本地队列来执行G(流程3.1),当从全局队列中没有获取到可执行的G时候,M会从其他P的局部队列中偷取G来执行(流程3.2),这种从其他P偷的方式称为**work stealing**
- 当G因系统调用阻塞(属于系统调用阻塞)时会阻塞M,此时P会和M解绑即**hand off**,并寻找新的idle的M,若没有idle的M就会新建一个M(流程5.1)
- 当G因channel(属于用户态阻塞)或者network I/O阻塞时,不会阻塞M,M会寻找其他runnable的G;当阻塞的G恢复后会重新进入runnable进入P队列等待执行(流程5.3)
work stealing 机制¶
获取 P 本地队列,当从绑定 P 本地 runq 上找不到可执行的 g,尝试从全局链表中拿,再拿不到从 netpoll 和事件池里拿,最后会从别的 P 里偷任务。P此时去唤醒一个 M。P 继续执行其它的程序。M 寻找是否有空闲的 P,如果有则将该 G 对象移动到它本身。接下来 M 执行一个调度循环(调用 G 对象->执行->清理线程→继续找新的 Goroutine 执行)。可以看出来work stealing机制包含了两阶段调度模型。
hand off 机制¶
当本线程 M 因为 G 进行的系统调用阻塞时,线程释放绑定的 P,把 P 转移给其他空闲的 M 执行。
细节:当发生上线文切换时,需要对执行现场进行保护,以便下次被调度执行时进行现场恢复。Go 调度器 M 的栈保存在 G 对象上,只需要将 M 所需要的寄存器(SP、PC 等)保存到 G 对象上就可以实现现场保护。当这些寄存器数据被保护起来,就随时可以做上下文切换了,在中断之前把现场保存起来。如果此时G 任务还没有执行完,M 可以将任务重新丢到 P 的任务队列,等待下一次被调度执行。当再次被调度执行时,M 通过访问 G 的 vdsoSP、vdsoPC 寄存器进行现场恢复(从上次中断位置继续执行)。
GMP 调度过程中存在哪些阻塞?¶
- I/O(其中网络层级IO已经实现用户级阻塞,不会handleoff M)
- block on syscall(系统级阻塞,会handoff M)
- channel/select(用户级阻塞)
- 等待锁
- runtime.Gosched() (主动handoff M)
GMP 中为什么需要P?¶
GM 调度存在的问题:
- 单一全局互斥锁(Sched.Lock)和集中状态存储
- Goroutine 传递问题(M 经常在 M 之间传递”可运行”的 goroutine)
- 每个 M 做内存缓存,导致内存占用过高,数据局部性较差
- 频繁 syscall 调用,导致严重的线程阻塞/解锁,加剧额外的性能损耗
Go中协作式抢占式调度存在的问题,以及后面如何解决了?¶
Go1.14 版本之前,Gorountine需要栈分裂时候,才能触发调度。这种方式存在问题有:
- 某些 Goroutine 可以长时间占用线程,造成其它 Goroutine 的饥饿(比如for循环)
- 垃圾回收需要暂停整个程序(Stop-the-world,STW),最长可能需要几分钟的时间,导致整个程序无法工作。
Go1.14之后开始支持基于信号的抢占式调度。为了防止执行信号的handle函数,发生栈溢出,每个Goroutine都有一个专门的信号栈。从细节来看具体原因是go1.14和go.1.13的调度器有一定的不同。
两者都支持抢占式调度,当go runtime启动时候,都会创建一个独立的M,称为sysmon,它既不关联P也不执行G,它是系统级线程。sysmon会检查go runtime中长时间运行的G,并进行抢占。
go1.13版本中,sysmon如果发现某个G运行时间超过10ms就会将该G标记为可抢占状态,此外G在运行过程中有一个函数栈分裂处理的逻辑,该处理逻辑会查看其是否被标记为可抢占状态,如果是那么其会让出其关联的M。
示例代码中for循序不会出现栈分裂的情况,所以G即使运行超过10ms也不会被抢占。由于所有的P关联的G都运行着for死循环,且不会被抢占,那么就没有多余的P可以执行fmt.Println语句了。
由于go1.13是基于函数栈分裂实现的抢占式调度,所以也称为半抢占式调度(即未完全实现抢占式调度)或协作抢占式调度。
go1.14版本为了解决类似示例代码中问题,引入了信号机制实现抢占式调度。sysmon发现某个G运行时间超过10ms,就会给该G发送一个信号(SIGURG),该G收到抢占调度信号后,会让出M。
Sysmon 有什么作用?¶
Go Runtime 在启动程序的时候,会创建一个独立的 M 作为监控线程,称为 sysmon,它是一个系统级的 daemon 线程。这个sysmon 独立于 GPM 之外,也就是说不需要P就可以运行。sysmon监控线程的功能有:
- 用于网络轮询器中,唤醒准备就绪的fd关联的goroutine
- 如果超过2分钟没有GC,则强制执行GC一次
- 抢占运行时间太长Goroutine(超过10ms的g,会进行retake)
- handle off长时间运行系统调用的M,即将M和P解绑,P重新找到空闲的M,执行任务,若没有空闲的M,则会创建一个。
- 定时器与滴答器的调度处理
- 打印schedule trace信息
defer 语法特点有哪些,底层实现机制?¶
概念¶
defer语法是用来定义一个延迟函数,遵循LIFO顺序。defer在运行过程遵循下面三条官方规则:
-
defer函数的传入参数在定义时就已经明确
-
defer函数是按照后进先出的顺序执行
-
defer函数可以读取和修改函数的命名返回值
原理¶
简单描述:
- 底层结构_defer结构体,多个defer函数构成_defer链表,后面的defer函数会插入链表头部,最后该链表挂载到G上面,执行时候从链表头部依次执行
- 为了减少创建_defer结构体的内存分配,Go采用了两层defer缓冲池,分别为per-P级别,这个是无锁的,goroutine有限从当前P中取。剩下一个是全局的defer缓存。
详细描述:
defer语法对应的底层数据结构是_defer结构体,多个defer函数会构建成一个_defer链表,后面加入的defer函数会插入链表的头部,该链表链表头部会链接到G上。当函数执行完成返回的时候,会从_defer链表头部开始依次执行defer函数。这也就是defer函数执行时会LIFO的原因。
创建_defer结构体是需要进行内存分配的,为了减少分配_defer结构体时资源消耗,Go底层使用了**两级defer缓冲池(defer pool)**,用来缓存上次使用完的_defer结构体,这样下次可以直接使用,不必再重新分配内存了。defer缓冲池一共有两级:per-P级defer缓冲池和全局defer缓冲池。当创建_defer结构体时候,优先从当前M关联的P的缓冲池中取得_defer结构体,即从per-P缓冲池中获取,这个过程是无锁操作。如果per-P缓冲池中没有,则在尝试从全局defer缓冲池获取,若也没有获取到,则重新分配一个新的_defer结构体。
测试题目:
func main() {
for i := 1; i <= 5; i++ {
defer fmt.Print(i) // 54321
}
fmt.Println(test1()) // 2
fmt.Println(test2()) // 1
fmt.Println(test3()) // 2
}
// 测试1
func test1() (i int) {
i = 1
defer func() {
i = i + 1
}()
return i
}
func test2() (r int) {
i := 1
defer func() {
i = i + 1
}()
return i
}
func test3() (r int) {
defer func(r int) {
r = r + 2
}(r)
return 2
}
适用场景¶
- 用户资源的释放操作
- 修改命名返回值
- 和recover关键字一起用于panic捕获
select可以用于做什么?¶
通道选择器,常用语gorotine的退出。golang 的 select 就是监听 IO 操作,当 IO 操作发生时,触发相应的动作,每个case语句里必须是一个IO操作,确切的说,应该是一个面向channel的IO操作。
- 监听exit通道
- or-done模式
映射¶
map是否是并发安全的,如何实现顺序读取,如何实现并发的map?¶
map中底层设计的知识点,key长度过长会不会影响map的读写效率?¶
访问映射涉及到key定位的问题,首先需要确定从哪个桶找,确定桶之后,还需要确定key-value具体存放在哪个单元里面(每个桶里面有8个坑位)。key定位详细流程如下:
- 首先需根据hash函数计算出key的hash值
- 该key的hash值的低
hmap.B位的值是该key所在的桶 - 该key的hash值的高8位,用来快速定位其在桶具体位置。一个桶中存放8个key,遍历所有key,找到等于该key的位置,此位置对应的就是值所在位置
- 根据步骤3取到的值,计算该值的hash,再次比较,若相等则定位成功。否则重复步骤3去
bmap.overflow中继续查找。 - 若
bmap.overflow链表都找个遍都没有找到,则返回nil。
删除map中元素时候并不会释放内存。删除时候,会清空映射中相应位置的key和value数据,并将对应的tophash置为emptyOne。此外会检查当前单元旁边单元的状态是否也是空状态,如果也是空状态,那么会将当前单元和旁边空单元状态都改成emptyRest。
Go语言中映射扩容采用渐进式扩容,避免一次性迁移数据过多造成性能问题。当对映射进行新增、更新时候会触发扩容操作然后进行扩容操作(删除操作只会进行扩容操作,不会进行触发扩容操作),每次最多迁移2个bucket。扩容方式有两种类型:
- 等容量扩容
- 双倍容量扩容
sync.Map的适合场景,如果是写多读少且支持并发怎么设计?¶
sync.Map适用于读多写少的场景。对于写多的场景,会导致 read map 缓存失效,需要加锁,导致冲突变多;而且由于未命中 read map 次数过多,导致 dirty map 提升为 read map,这是一个 O(N) 的操作,会进一步降低性能。
- sync.Map采用空间换时间策略。其底层结构存在两个map,分别是read map和dirty map。当读取操作时候,优先从read map中读取,是不需要加锁的,若key不存在read map中时候,再从dirty map中读取,这个过程是加锁的。当新增key操作时候,只会将新增key添加到dirty map中,此操作是加锁的,但不会影响read map的读操作。当更新key操作时候,如果key已存在read map中时候,只需无锁更新更新read map就行,同时负责加锁处理在dirty map中情况了。总之sync.Map会优先从read map中读取、更新、删除,因为对read map的读取不需要锁
- 当sync.Map读取key操作时候,若从read map中一直未读到,若dirty map中存在read map中不存在的keys时,则会把dirty map升级为read map,这个过程是加锁的。这样下次读取时候只需要考虑从read map读取,且读取过程是无锁的
为什么不使用sync.Mutex+map实现并发的map呢?¶
这个问题可以换个问法就是sync.Map相比sync.Mutex+map实现并发map有哪些优势?
sync.Map优势在于当key存在read map时候,如果进行Store操作,可以使用原子性操作更新,而sync.Mutex+map形式每次写操作都要加锁,这个成本更高。
另外并发读写两个不同的key时候,写操作需要加锁,而读操作是不需要加锁的。
通道¶
channel有哪几种类型?有哪些特点?底层数据是怎么样的?是否是并发安全的,以及怎么做到并发安全的?¶
channel收发遵循FIFO原则,其底层是hchan结构指针,创建通道使用make关键字。对于有缓存的通道,其底层是固定大小的循环队列。由于对通道读取、写入时候会加锁,所以是并发安全的。当channel因为缓冲区不足而阻塞队列时候,则使用双向链表存储。Go语言中,不要通过共享内存来通信,而要通过通信实现内存共享。Go的CSP(Communicating Sequential Process)并发模型,中文可以叫做通信顺序进程,是通过 goroutine 和 channel 来实现的。
通道类型有:
- 有缓存通道/无缓冲通道
- 读写通道/只读通道/只写通道
特点有:
- 读写nil通道,永远阻塞。关闭nil通道会panic
- 读一个已关闭的通道,如果缓存区为空时候,则返回一个零值。可以使用for-range或者逗号ok
- 写一个已关闭的通道,会panic
内置的cap函数可以用于哪些内容?¶
- array
- slice
- channel
为啥 channel 会有 close 这个操作, 在哪些场景下会用到这个操作 ?¶
在 Go 语言中,channel 的 close 操作用于向 channel 的接收方明确地通知发送操作已经完成。关闭一个 channel 可以表达“没有更多的数据将被发送到这个 channel”这一信号。这是一种控制信号,帮助接收方理解数据流的生命周期,并且可以避免在 channel 上进行无限等待。
使用 close 的场景¶
-
通知多个接收者完成处理:
当使用一个 channel 来分发任务或数据给多个协程(goroutines)时,关闭 channel 是一种告知所有接收者没有更多数据要处理的有效方法。接收者可以通过检测 channel 是否已关闭来适时停止处理。
-
控制循环退出:
在接收数据时,可以使用 for range 循环从 channel 接收数据。当 channel 被关闭,并且 channel 中已经没有待处理的数据时,for range 循环会自动结束。这使得编码简洁,并且逻辑清晰。
-
防止资源泄露:
如果不关闭不再使用的 channel,可能会导致内存资源没有得到释放,特别是在 channel 还保持着一些数据项的情况下。尽管 Go 的垃圾回收机制会回收未引用的对象,但显式关闭 channel 是一个好的实践,它可以清晰地表达程序设计者的意图。
-
使用 select 的默认操作:
在使用 select 语句处理多个 channel 的时候,关闭一个 channel 可以用于触发其他 case 的执行。特别是在一些需要优雅退出的并发模式中,关闭 channel 可以促使 select 快速响应并处理结束逻辑。
示例:数据处理和广播信号¶
假设有一个数据处理任务,需要将数据分批发送到多个处理协程,处理完成后再汇总结果。这里可以使用关闭 channel 的方式来告知所有处理协程,数据已经发送完毕:
func processData(dataChunks [][]int) []int {
var results []int
resultChan := make(chan int)
dataChan := make(chan int, 100)
// 启动多个工作协程
for i := 0; i < 5; i++ {
go func() {
for data := range dataChan {
result := process(data) // 假设有一个处理函数
resultChan <- result
}
}()
}
// 发送数据
go func() {
for _, chunk := range dataChunks {
for _, data := range chunk {
dataChan <- data
}
}
close(dataChan)
}()
// 接收结果
go func() {
for i := 0; i < len(dataChunks); i++ {
result := <-resultChan
results = append(results, result)
}
close(resultChan)
}()
return results
}
在这个示例中,通过关闭 dataChan 来告知工作协程不会再有新的数据发送,这时协程可以结束从 channel 接收数据的操作。关闭 resultChan 则用来表示所有结果已经处理完毕,可以进行后续步骤。
总结来说,关闭一个 channel 是一种向接收方传递完成信号的方法,它在多协程协作的环境中尤为有用,有助于提高代码的可读性和安全性。
Go如何避免内存的对象频繁分配和回收的问题?¶
可以考虑使用对象缓存池sync.Pool
Go如何进行并发竞态检测,如何避免竞态问题?¶
Go支持go run/test/build 使用-race选项进行竞态检查。可以使用锁、信号量等同步手段保护临界区,或者原子操作等手段避免竞态问题。
如何实现循环队列?¶
channel或者atomic实现。
锁¶
锁种类¶
- 写锁-sync.Mutex,属于排他锁(或互斥锁)
- 读写锁-sync.RWMutex,属于共享锁
这两种锁的对象单元都是goroutine,底层用到类似信号机制。在runtime时也有mutex锁,底层使用futex系统调用,锁的对象是线程M,它还会阻止相关联的 G 和 P 被重新调度。
所有锁使用时候需要指针传递,也就是nocopy机制。此外Go内置的锁也不是可重入的。
sync.Mutex的工作模式¶
Mutex 一共有下面几种状态:
- mutexLocked — 表示互斥锁的锁定状态;
- mutexWoken — 表示从正常模式被从唤醒;
- mutexStarving — 当前的互斥锁进入饥饿状态;
- waitersCount — 当前互斥锁上等待的 Goroutine 个数;
正常模式和饥饿模式:
对于两种模式,正常模式下的性能是最好的,goroutine 可以连续多次获取锁,饥饿模式解决了取锁公平的问题,但是性能会下降,这其实是性能和公平的一个平衡模式。
-
正常模式(非公平锁)
正常模式下,所有等待锁的 goroutine 按照 FIFO(先进先出)顺序等待。唤醒的 goroutine 不会直接拥有锁,而是会和新请求 goroutine 竞争锁。新请求的goroutine 更容易抢占:因为它正在 CPU 上执行,所以刚刚唤醒的 goroutine有很大可能在锁竞争中失败。在这种情况下,这个被唤醒的 goroutine 会加入到等待队列的前面。
-
饥饿模式(公平锁)
为了解决了等待 goroutine 队列的长尾问题。饥饿模式下,直接由 unlock 把锁交给等待队列中排在第一位的 goroutine (队头),同时,饥饿模式下,新进来的 goroutine 不会参与抢锁也不会进入自旋状态,会直接进入等待队列的尾部。这样很好的解决了老的 goroutine 一直抢不到锁的场景。
饥饿模式的触发条件:当一个 goroutine 等待锁时间超过 1 毫秒时,或者当前队列只剩下一个 goroutine 的时候,Mutex 切换到饥饿模式。
Mutex运行自旋的条件有:
- 锁已被占用,并且锁不处于饥饿模式。
- 积累的自旋次数小于最大自旋次数(active_spin=4)。
- CPU 核数大于 1。有空闲的 P。
- 当前 Goroutine 所挂载的 P 下,本地待运行队列为空。
RWMutex实现原理?以及在使用过程中需要注意事项?¶
RWMutex是读写锁,用于解决读者-写者问题,并且是写者优先的锁。如果有写者提出申请资源,在申请之前已经开始读取操作的可以继续执行读取,但是如果再有读者申请读取操作,则不能够读取,只有在所有的写者写完之后才可以读取。写者优先解决了读者优先造成写饥饿的问题
type RWMutex struct {
w Mutex // 互斥锁
writerSem uint32 // writers信号量
readerSem uint32 // readers信号量
readerCount int32 // reader数量
readerWait int32 // writer申请锁时候,已经申请到锁的reader的数量
}
对于读者优先(readers-preference)的读写锁,只需要一个**readerCount**记录所有读者,就可以轻易实现。Go中的RWMutex实现的是写者优先(writers-preference)的读写锁,那就需要用到**readerWait**来记录写者申请锁时候,已经获取到锁的读者数量。
这样当后续有其他读者继续申请锁时候,可以读取readerWait是否大于0,大于0则说明有写者已经申请锁了,按照写者优先(writers-preference)原则,该读者需要排到写者之后,但是我们还需要记录这些排在写者后面读者的数量呀,毕竟写着将来释放锁的时候,还得一个个唤醒这些读者。这种情况下既要读取readerWait,又要更新排队的读者数量readerCount,这是两个操作,无法原子化。RWMutex在实现时候,通过将readerCount转换成负数,一方面表明有写者申请了锁,另一方面readerCount还可以继续记录排队的读者数量,解决刚描述的无法原子化的问题,真是巧妙!
错误的使用场景:
- RLock/RUnlock、Lock/Unlock未成对出现
- 复制sync.RWMutex作为函数值传递
- 不可重入导致死锁
sync.WaitGroup用法以及实现原理?¶
sync.WaitGroup用于等待一组协程完成。
sync.WaitGroup维护了2个计数器,一个是请求计数器,每次执行Add时候,该计数器会加1,另外一个是等待计数器,每次执行Wait时候,该计数器会加1。当执行Done时候,会将请求计数器减一,当请求计数器为0时候,会唤醒等待的等待者。
需要注意的时候Add()和Wait() 不能并发调用。
sync.Once用法¶
sync.Once用来执行且执行一次动作,常常用于单例对象初始化场景。
什么是CAS?¶
CAS全称为Compare And Swap,中文翻译为比较交换,是一条原子指令,对应cmpxchg指令,其原理是先比较两个值是否相等,然后原子地更新某个位置的值。基于CAS我们可以实现一个自旋锁,无锁堆栈。基于CAS实现的无锁数据结构中,需要注意ABA问题。
sync.Pool的用法以及实现原理?¶
频繁地分配,回收内存会给GC带来一定负担,严重时候,会引起CPU的毛刺现象,而通过sync.Pool可以将暂时不用的对象缓存起来,等下次需要时候直接使用,不用再次经过内存分配,复用对象的内存,减轻GC的压力,提升系统的性能。
sync.Pool提供了临时对象缓存池,存在池子的对象可能在任何时刻被自动移除,我们对此不能做任何预期。sync.Pool可以并发使用,它通过复用对象来减少对象内存分配和GC的压力。当负载大的时候,临时对象缓存池会扩大,缓存池中的对象会在每2个GC循环中清除。
sync.Pool拥有两个对象存储容器:local pool和victim cache。local pool与victim cache相似,相当于primary cache。当获取对象时,优先从local pool中查找,若未找到则再从victim cache中查找,若也未获取到,则调用New方法创建一个对象返回。当对象放回sync.Pool时候,会放在local pool中。当GC开始时候,首先将victim cache中所有对象清除,然后将local pool容器中所有对象都会移动到victim cache中,所以说缓存池中的对象会在每2个GC循环中清除。
若G关联的per-P级poolLocal的双端队列中没有取出来对象,那么就尝试从其他P关联的poolLocal中偷一个。若从其他P关联的poolLocal没有偷到一个,那么就尝试从victim cache中取。
若步骤4中也没没有取到缓存对象,那么只能调用pool.New方法新创建一个对象。
如何避免死锁?¶
死锁检测,活锁,银行家算法
Go中内存逃逸是怎么回事?怎么检测内存逃逸?有哪些内存逃逸的场景?¶
Go 语言中决定一个变量分配栈上还是堆是Go编译器决定的,如果变量分配到堆上那么我们就说着变量发生了逃逸。我们设置-gcflags=”-m”来检测内存逃逸。内存逃逸的场景一般有:
- 函数返回局部变量的指针(一般会,并不绝对)
- 闭包中捕获变量会发生更改时候
- 切片变量过大时候
实现一个并发安全的set?¶
type inter interface{}
type Set struct {
m map[inter]bool
sync.RWMutex
}
func New() *Set {
return &Set{
m: map[inter]bool{},
}
}
func (s *Set) Add(item inter) {
s.Lock()
defer s.Unlock()
s.m[item] = true
}
主协程如何等其余协程完再操作?¶
sync.Waitgroup
struct结构能不能比较?¶
这个设计到Go语言中可比较性规则。
- 切片、映射、函数不可比较,但都可以和nil比较
- 当通道元素类型一样时候,可以比较,即使缓冲大小不一样
- 指针类型只有指向的变量的类型一样时候,才能够比较。但都可以和nil比较
- 接口类型都可以相互比较,只有底层类型和底层值一样时候,才会相等
- 数组类型,只有元素类型和数组大小一样时候,才可以进行比较
-
如果结构体中所有字段都是可以比较的,那么该结构体就是可以比较的。注意:字段比较时候需要按照相同顺序依次比较。
var t1 = struct { A string B string }{} var t2 = struct { B string A string }{} var t3 = struct { A string B string c int // unexport }{} fmt.Println(t1 == t2) // 不能比较 fmt.Println(t1 == t3) // 不能比较 // invalid operation: t1 == t2 (mismatched types struct{A string; B string} and struct{B string; A string}) // invalid operation: t1 == t3 (mismatched types struct{A string; B string} and struct{A string; B string; c int})
Go里面的值传递和指针传递?¶
函数参数传递方式一般有两种:值传递和引用传递。其中值传递中可以传递指针,这种情况可以称为指针传递。指针传递不等于引用传递,尽管两者都可以改变原始值。
Go语言中所有都是值传递。切片,通道,映射属于指针传递,因为它们底层是一个指针(或者是胖指针)
context包的用途?¶
context.Context的作用就是在不同的goroutine之间同步请求特定数据、取消信号以及处理请求的截止日期。
字符串有哪几种拼接方式?性能怎么样?¶
字符串底层结构本质是一个fat-pointer:
- +号拼接,会产生临时字符串,性能一般
- fmt.Printf 进行拼接,由于字符串会变成interface{},产生内存逃逸,性能较差
- strings.Join 用于字符串切片拼接,底层用到了strings.Builder,性能比较高
- strings.Builder 性能高,底层用到内存缓冲,内存缓冲结构是字节切片,输出字符串时候使用了zero-copy技术直接把字节切片转换成字符串。缺点就是每次reset时候都会将内存缓冲至为nil,不能够复用
- bytes.Buffer 性能高,跟strings.Builder类似,但reset时候不会将内存缓冲至为nil,能够达到复用的目的
Go 数组与C语言数组有什么区别?¶
Go语言中数组是一片连续的内存,是**一个值类型**,作为参数传递时候会把COPY旧数组形成一个新数组作为函数的参数。这也意味着在函数内改变数组值,不会影响原数组。
slice的len,cap知识,底层共享等问题,以及扩容策略?¶
切片概念¶
Go中切片是动态数组的概念,底层结构类似字符串,但其指针指向的内存是可以更改的,并且它还有一个容量字段。
切片作为参数传递时候也是值传递,但它传递的是指针,属于指针传递,所以它拥有引用传递的特性。
为了避免切片指针传递带来的副作用,可以使用内置copy函数复制一个全新的切片再传递。
创建方式¶
切片的创建方式有:
- 使用make关键字创建,形式make([]T, length, capacity),capacity可以省略,默认等于length
-
基于数组,指向数组的指针,切片构建一个切片
reslice操作语法可以是[]T[low : high],也可以是[]T[low : high : max]。其中low,high,max都可以省略,low默认值是0,high默认值cap([]T),max默认值cap([]T)。low,hight,max取值范围是
0 <= low <= high <= max <= cap([]T),其中high-low是新切片的长度,max-low是新切片的容量。对于[]T[low : high],其包含的元素是[]T中下标low开始,到high结束(不含high所在位置的,相当于左闭右开[low, high))的元素,元素个数是high - low个,容量是cap([]T) - low。
-
使用字面量创建
reslice¶
基于切片或者数组reslice一个新切片时候,需要注意新切片的容量:
func main() {
slice1 := make([]int, 0)
slice2 := make([]int, 1, 3)
slice3 := []int{}
slice4 := []int{1: 2, 3}
arr := []int{1, 2, 3}
slice5 := arr[1:2]
slice6 := arr[1:2:2]
slice7 := arr[1:]
slice8 := arr[:1]
slice9 := arr[3:]
slice10 := slice2[1:2]
fmt.Printf("%s = %v,\t len = %d, cap = %d\n", "slice1", slice1, len(slice1), cap(slice1))
fmt.Printf("%s = %v,\t len = %d, cap = %d\n", "slice2", slice2, len(slice2), cap(slice2))
fmt.Printf("%s = %v,\t len = %d, cap = %d\n", "slice3", slice3, len(slice3), cap(slice3))
fmt.Printf("%s = %v,\t len = %d, cap = %d\n", "slice4", slice4, len(slice4), cap(slice4))
fmt.Printf("%s = %v,\t len = %d, cap = %d\n", "slice5", slice5, len(slice5), cap(slice5))
fmt.Printf("%s = %v,\t len = %d, cap = %d\n", "slice6", slice6, len(slice6), cap(slice6))
fmt.Printf("%s = %v,\t len = %d, cap = %d\n", "slice7", slice7, len(slice7), cap(slice7))
fmt.Printf("%s = %v,\t len = %d, cap = %d\n", "slice8", slice8, len(slice8), cap(slice8))
fmt.Printf("%s = %v,\t len = %d, cap = %d\n", "slice9", slice9, len(slice9), cap(slice9))
fmt.Printf("%s = %v,\t len = %d, cap = %d\n", "slice10", slice10, len(slice10), cap(slice10))
}
上面输出:
slice1 = [], len = 0, cap = 0
slice2 = [0], len = 1, cap = 3
slice3 = [], len = 0, cap = 0
slice4 = [0 2 3], len = 3, cap = 3
slice5 = [2], len = 1, cap = 2
slice6 = [2], len = 1, cap = 1
slice7 = [2 3], len = 2, cap = 2
slice8 = [1], len = 1, cap = 3
slice9 = [], len = 0, cap = 0
slice10 = [0], len = 1, cap = 2
扩容策略¶
切片的扩容策略是:
- 首先判断,如果新申请容量大于 2 倍的旧容量,最终容量就是新申请的容量
- 否则判断,如果旧切片的长度小于 1024,则最终容量就是旧容量的两倍
- 否则判断,如果旧切片长度大于等于 1024,则最终容量从旧容量开始循环增加原来的 ¼, 直到最终容量大于等于新申请的容量。由于考虑内存对齐,最终实际扩容大小可能会大于¼
常见用法¶
//copy
b = make([]T, len(a))
copy(b, a)
//cut
a = append(a[:i], a[j:]...)
//delte
a = append(a[:i], a[i+1:]...)
// or
a = a[:i+copy(a[i:], a[i+1:])]
// insert
s = append(s, 0)
copy(s[i+1:], s[i:])
s[i] = x
//pop
x, a = a[len(a)-1], a[:len(a)-1]
//push
a = append(a, x)
//shift
x, a := a[0], a[1:]
//unshift
a = append([]T{x}, a...)
//反转
for left, right := 0, len(a)-1; left < right; left, right = left+1, right-1 {
a[left], a[right] = a[right], a[left]
}
字符串与切片内存zero-copy转换的实现?¶
func bytes2string(b []byte) string{
return *(*string)(unsafe.Pointer(&b))
}
func StringToBytes(s string) (b []byte) {
sh := *(*reflect.StringHeader)(unsafe.Pointer(&s))
bh := (*reflect.SliceHeader)(unsafe.Pointer(&b))
bh.Data, bh.Len, bh.Cap = sh.Data, sh.Len, sh.Len
return b
}
func StringToBytes(s string) []byte {
return *(*[]byte)(unsafe.Pointer(
&struct {
string
Cap int
}{s, len(s)},
))
}
make与new的区别?¶
- Go 中make关键字用来创建切片,通道,映射,返回是引用类型本身,new返回的是指向类型的指针。new返回的类型指针指向的值为该类型的零值。由于new不会初始化内存,只是清零内存,所以new切片,通道,映射之后,并不能直接使用:
type User struct {
name string
}
func main() {
puser := new(User)
puser.name = "hello"
fmt.Println(*puser)
pint := new(int)
*pint = 123
fmt.Println(*pint) // 123
parr := new([5]int)
(*parr)[1] = 123
fmt.Println(parr) // &[0 123 0 0 0]
pslice := new([]int)
(*pslice)[0] = 8 // /panic: runtime error: index out of range
pmap := new(map[string]string)
(*pmap)["a"] = "a" // panic: assignment to entry in nil map
pchan := new(chan string)
pchan <- "good" //invalid operation: cv <- "good" (send to non-chan type *chan string)
}
nil 的概念¶
对应于引用类型的变量,它的零值是nil。零值指的是当声明变量且未显示初始化时,Go语言会自动给变量赋予一个默认初始值。
- 对nil通道读写操作会永远阻塞
- 对nil切片,可以append操作,读写会panic
- 对nil映射读取和删除ok,写入会panic
- nil可以作为接收者,只不是值为nil而已
Go语言中指针与非安全指针类型概念?¶
对于任意类型T,其对应的的指针类型是*T,类型T称为指针类型*T的基类型。 一个指针类型*T变量B存储的是类型T变量A的内存地址,我们称该指针类型变量B**引用(reference)了A。从指针类型变量B获取(或者称为访问)A变量的值的过程,叫解引用** 。解引用是通过解引用操作符*操作的。
Go中unsafe.Pointer是非安全类型指针,它作为桥梁,用于任意类型指针与uintptr互换。
type MyInt int
func main() {
a := 100
fmt.Printf("%p\n", &a)
fmt.Printf("%x\n", uintptr(unsafe.Pointer(&a)))
}
三色标记法原理¶
Golang中采用 三色标记清除算法(tricolor mark-and-sweep algorithm) 进行GC。由于支持写屏障(write barrier)了,GC过程和程序可以并发运行。
三色标记清除算核心原则就是根据每个对象的颜色,分到不同的颜色集合中,对象的颜色是在标记阶段完成的。三色是黑白灰三种颜色,每种颜色的集合都有特别的含义:
-
黑色集合
该集合下的对象没有引用任何白色对象(即该对象没有指针指向白色对象)
-
白色集合
扫描标记结束之后,白色集合里面的对象就是要进行垃圾回收的,该对象允许有指针指向黑色对象。
-
灰色集合
可能有指针指向白色对象。它是一个中间状态,只有该集合下不在存在任何对象时候,才能进行最终的清除操作。
GC流程¶
当垃圾回收开始,全部对象标记为白色。
- 垃圾回收器会遍历所有根对象并把它们标记为灰色,放入灰色集合里面。**根对象**就是程序能直接访问到的对象,包括全局变量以及栈、寄存器上的里面的变量。
- 遍历灰色集合中的对象,把灰色对象引用的白色集合的对象放入到灰色集合中,同时把遍历过的灰色集合中的对象放到黑色的集合中
- 重复步骤2,直到灰色集合没有对象
- 步骤3结束之后,白色集合中的对象就是不可达对象,也就是垃圾,可以进行回收
为了支持能够并发进行垃圾回收,Golang在垃圾回收过程中采用写屏障,每次堆中的指针被修改时候写屏障都会执行,写屏障会将该指针指向的对象标记为灰色,然后放入灰色集合(因为才对象现在是可触达的了),然后继续扫描该对象。
举个例子说明写屏障的重要性:
假定标记完成的瞬间,A对象是黑色,B是白色,然后A的对象指针字段f由空指针改成指向B,若没有写屏障的话,清除阶段B就会被清除掉,那边A的f字段就变成了悬浮指针,这是有问题的。若存在写屏障那么f字段改变的时候,f指向的B就会放入到灰色集合中,然后继续扫描,B最终也会变成黑色的,那么清除阶段它也就不会被清除了。
除了三色标记法外还有标记清除法,标记清除法的最大弊端就是在整个GC期间需要STW。
虽然 golang 是先实现的插入写屏障,后实现的混合写屏障,但是从理解上,应该是先理解删除写屏障,后理解混合写屏障会更容易理解;
插入写屏障没有完全保证完整的强三色不变式(栈对象的影响),所以赋值器是灰色赋值器,最后必须 STW 重新扫描栈;
混合写屏障消除了所有的 STW,实现的是黑色赋值器,不用 STW 扫描栈;
混合写屏障的精度和删除写屏障的一致,比以前插入写屏障要低;
混合写屏障扫描栈式逐个暂停,逐个扫描的,对于单个 goroutine 来说,栈要么全灰,要么全黑;
暂停机制通过复用 goroutine 抢占调度机制来实现;
写屏障是什么_Golang 混合写屏障原理深入剖析,这篇文章给你梳理的明明白白!
强三色不变式规则:不允许黑色对象引用白色对象
破坏了条件一: 白色对象被黑色对象引用
解释:如果一个黑色对象不直接引用白色对象,那么就不会出现白色对象扫描不到,从而被当做垃圾回收掉的尴尬。
弱三色不变式规则:黑色对象可以引用白色对象,但是白色对象的上游必须存在灰色对象
破坏了条件二:灰色对象与白色对象之间的可达关系遭到破坏
解释: 如果一个白色对象的上游有灰色对象,则这个白色对象一定可以扫描到,从而不被回收
混合写屏障的具体核心规则如下:
-
GC开始后先将栈上的**可达对象**全部扫描并标记为黑色(之后不再进行第二次重复扫描,无需STW)
-
GC期间,任何在栈上创建的新对象,均为黑色。
-
(堆上)被删除的对象标记为灰色。
4.(堆上)被添加的对象标记为灰色。
场景一:栈对象A的下游引用一个堆对象C,接着该堆对象C被引用它的堆对象B删除。
- 栈A引用(即指向)对象C,由于没有写屏障,C对象不会做任何更改
- 堆对象B删除掉引用C,由于堆上删除写屏障的存在,那么C如果是灰色和白色的,那C就会标记成灰色
GC触发时机¶
-
主动触发
调用runtime.GC
-
内存分配至时候被动触发
由mallocgc()发起的,触发条件是堆大小达到或者超过了临界值。使用步调(Pacing)算法,其核心思想是控制内存增长的比例。如 Go 的 GC是一种比例 GC, 下一次 GC 结束时的堆大小和上一次 GC 存活堆大小成比例.
-
基于时间的周期性触发
由系统监控sysmon发起,该触发条件由 runtime.forcegcperiod 变量控制,默认为 2 分钟。当超过两分钟没有产生任何 GC 时,强制触发 GC。
辅助GC的目的是?¶
辅助GC是mallocgc()函数的一部分,mallocgc()函数式堆分配的关键函数,runtime中new系列函数和make系列函数都依赖它。mallocgc()只有在GC标记阶段才执行辅助GC,并且每个goroutine都已辅助GC的字节额度,超过就不行辅助GC了。辅助GC机制能够优有限避免程序过快地分配内存,从而造成GC工作线程(gc worker)来不及标记的问题。
GC如何调优¶
通过 go tool pprof 和 go tool trace 等工具
- 控制内存分配的速度,限制 Goroutine 的数量,从而提高赋值器对 CPU
的利用率。
- 减少并复用内存,例如使用 sync.Pool 来复用需要频繁创建临时对象,例
如提前分配足够的内存来降低多余的拷贝。
- 需要时,增大 GOGC 的值,降低 GC 的运行频率。
- 对于预分配的大量内存,则可能需要将 debug.SetGCPercent() 设置为低得多的百分比才能获得正常的 GC 频率。
reflect反射三定律?¶
-
Reflection goes from interface value to reflection object
反射可以将“接口类型变量”转换为“反射类型对象”
-
Reflection goes from reflection object to interface value
反射可以将“反射类型对象”转换为“接口类型变量”
-
To modify a reflection object, the value must be settable
如果要修改“反射类型对象”,其值必须是“可写的”(settable)
Go pprof¶
pprof支持以下几种分析器:
-
Go 分析器
CPU 分析器通过操作系统监控应用程序的CPU 使用情况,并且每隔10ms的CPU 片时间发送一个SIGPROF信号来捕获profile数据。操作系统还包括内核在此监控中代表应用程序消耗的时间。由于信号传输速率取决于 CPU 消耗,因此它是动态的,最高可达 N * ``100Hz,其中 N是操作系统上逻辑 CPU 内核的数量。当 SIGPROF信号到达时,Go 的信号处理程序捕获当前活动的 goroutine 的堆栈跟踪,并增加profile文件中的相应值。 cpu/nanoseconds值目前是直接从samples/count样本计数中推导出来的,所以是多余的,但是使用方便。
-
内存分析器
-
阻塞分析器
Go 中的阻塞分析器衡量你的 goroutine 在等待通道以及sync包提供的互斥操作时在 Off-CPU 外花费的时间。以下 Go 操作会被阻塞分析器捕获分析:
- select
- chan send
- chan receive
- semacquire (
Mutex.Lock,RWMutex.RLock,RWMutex.Lock,WaitGroup.Wait) - notifyListWait (
Cond.Wait)
阻塞 profile文件不包括等待 I/O、睡眠、GC 和各种其他等待状态的时间。此外,阻塞事件在完成之前不会被记录,因此阻塞profile文件不能用于调试 Go 程序当前挂起的原因。后者可以使用 Goroutine 分析器确定。
Go内存分配原理?¶
Golang内存分配管理策略是**按照不同大小的对象和不同的内存层级来分配管理内存**。通过这种多层级分配策略,形成无锁化或者降低锁的粒度,以及尽量减少内存碎片,来提高内存分配效率。
Golang中内存分配管理的对象按照大小可以分为:
| 类别 | 大小 |
|---|---|
| 微对象 tiny object | (0, 16B) |
| 小对象 small object | [16B, 32KB] |
| 大对象 large object | (32KB, +∞) |
Golang中内存管理的层级从最下到最上可以分为:mspan -> mcache -> mcentral -> mheap -> heapArena。golang中对象的内存分配流程如下:
- 小于16个字节的对象使用
mcache的微对象分配器进行分配内存 - 大小在16个字节到32k字节之间的对象,首先计算出需要使用的
span大小规格,然后使用mcache中相同大小规格的mspan分配 - 如果对应的大小规格在
mcache中没有可用的mspan,则向mcentral申请 - 如果
mcentral中没有可用的mspan,则向mheap申请,并根据BestFit算法找到最合适的mspan。如果申请到的mspan超出申请大小,将会根据需求进行切分,以返回用户所需的页数,剩余的页构成一个新的mspan放回mheap的空闲列表 - 如果
mheap中没有可用span,则向操作系统申请一系列新的页(最小 1MB) - 对于大于32K的大对象直接从
mheap分配
mspan:
mspan是一个双向链表结构。mspan是golang中内存分配管理的基本单位。span大小一共有67个规格。规格列表如下, 其中class = 0 是特殊的span,用于大于32kb对象分配,是直接从mheap上分配的。
mcache:
mcache持有一系列不同大小的mspan。mcache属于per-P cache,由于M运行G时候,必须绑定一个P,这样当G中申请从mcache分配对象内存时候,无需加锁处理。
mcetral:
当mcache的中没有可用的span时候,会向mcentral申请。
Go错误处理¶
为了不丢失函数调用的错误链,使用fmt.Errorf时搭配使用特殊的格式化动词%w,可以实现基于已有的错误再包装得到一个新的错误。
对于这种二次包装的错误,errors包中提供了以下三个方法。
func Unwrap(err error) error // 获得err包含下一层错误
func Is(err, target error) bool // 判断err是否包含target
func As(err error, target interface{}) bool // 判断err是否为target类型
一篇文章带你轻松搞懂Golang的error处理_Golang_脚本之家
Go错误处理机制为啥不采用Java的try-catch的异常机制?¶
Go 语言选择不使用 Java 中的异常机制,而是采用返回错误(error)的方式处理异常情况,这主要是基于几个设计目标和考虑:
-
简明性和可预测性:在 Go 语言中,错误被视为常见的、可预料的结果,而不是异常情况。通过显式地返回错误,程序员被迫处理错误,这可以使得错误处理更加显式和清晰,减少了忽视错误处理的可能性。
-
控制流简单化:使用异常机制时,代码的执行流可以因为异常的抛出而在任何点被中断,这使得程序的控制流变得复杂和难以跟踪。相反,通过返回错误,错误处理和正常逻辑清晰地分离,控制流更加直观。
-
性能考虑:异常处理机制通常涉及栈解 unwinding 过程,这在性能上可能带来开销。Go 语言通过简单的值返回避免了这种开销。
-
交叉语言调用简化:Go 语言在设计时考虑到了与 C 语言的互操作性。在 C 语言中没有异常机制,使用错误码是常见做法。Go 的错误处理方式与之兼容,简化了跨语言调用的复杂性。
关于是否是一个好的设计,这取决于具体的使用场景和个人偏好。这种设计强化了错误处理的重要性,并鼓励开发者更加关注程序可能出现的错误情况,从而可能增强程序的健壮性。但是,这也可能导致代码中充满大量的错误处理逻辑,有时可能显得冗余。
Java 的异常机制有其优点,特别是在错误需要跨多个调用层级传播时,异常提供了一种非常便捷的方式。然而,它也有一些常见的问题:
- 异常被忽略:Java 中的异常可以被捕获后不做任何处理,这可能隐藏程序中的错误。
- 代码复杂性:异常的抛出和捕获可能使得程序逻辑变得难以理解和维护,特别是当异常可以在多个层级被抛出和捕获时。
- 性能问题:异常处理可能引入性能损耗,特别是在异常频繁抛出的场景中。
总体来说,Go 的错误处理方式倾向于更加显式和可控,而 Java 的异常机制则提供了一种灵活但有时可能不够清晰的错误处理策略。选择哪种方式更好,很大程度上取决于特定项目的需求和开发团队的偏好。
Go为什么采用组合机制而不是使用继承机制?¶
Go 语言没有传统意义上的类和继承机制,这是其设计哲学中的一部分,旨在简化语言和避免常见的面向对象编程的一些复杂性和问题。下面是 Go 语言这样设计的一些原因及其替代方式:
为什么 Go 没有传统的继承?¶
-
简化语言设计:Go 的设计哲学是保持语言的简洁和高效。继承是一个强大但复杂的功能,可以导致多种编程问题,如复杂的依赖关系和难以预测的行为。
-
避免继承带来的问题:
- 脆弱的基类问题:基类的改变可能影响到大量的派生类。
- 深层继承结构导致的复杂性:随着继承链的增长,理解和维护代码变得更加困难。
- 多重继承的复杂性:如 C++ 中的多重继承可能导致菱形继承问题,增加了语言和编译器的复杂性。
Go 如何实现多态?¶
尽管 Go 没有继承,它通过接口来支持多态性。在 Go 中,接口是一组方法签名的集合,任何类型只要实现了这些方法,就被认为实现了该接口。这种方式与继承不同,更加灵活和简洁:
- 接口隐式实现:类型不需要声明它实现了哪个接口,这降低了代码之间的耦合。
- 组合优于继承:Go 通过组合(有时候通过嵌入结构体)来实现代码的复用,这比继承更加直接和清晰。
Embedded Struct 算不算继承?¶
Embedded struct(嵌入结构体)在 Go 中被用作实现类似继承的功能,但它更准确地被描述为组合。通过嵌入一个结构体,一个新的结构体可以直接访问嵌入结构体的方法和字段,这提供了一种方式来复用代码:
- 不是真正的继承:虽然看起来类似,嵌入结构体并不提供传统意义上的多态。
- 代码复用和扩展:它允许一种灵活的方式来扩展功能,而无需继承的复杂性。
传统继承的问题¶
- 过度耦合:子类和父类之间的关系过于紧密,改动父类可能会影响所有子类。
- 隐藏的复杂性:继承可以导致代码的行为不透明,增加理解和调试的难度。
- 难以正确使用:正确地设计和维护一个继承体系需要大量的设计经验和技术洞察力。
Go 的设计选择鼓励开发者采用更简单、更易于理解和维护的编程范式。通过接口和组合,Go 提供了一种强大的工具集来建构灵活且可维护的代码结构,避免了许多传统面向对象编程中常见的陷阱。
Go 中 channel 跟 Java 中 BlockingQueue 又有啥区别 ?¶
Go 的 channel 和 Java 的 BlockingQueue 都是用于不同线程或协程间的通信机制,但它们的设计哲学和使用场景有所不同。这两种机制都用于解决并发编程中的同步问题,但具体的实现和适用的场景有差异。
Channel 与 BlockingQueue 的区别¶
-
设计哲学:
- Go 的 Channel:Channel 是 Go 语言中的一等公民,用于在协程(goroutines)之间进行通信。它遵循“通过通信来共享内存,而不是通过共享内存来通信”的哲学。
- Java 的 BlockingQueue:是 Java 并发包中的一部分,主要用于线程间的通信,尤其在生产者-消费者模型中。它依赖于共享内存和锁来实现线程安全。
-
功能实现:
- Channel 支持多种模式,如无缓冲、有缓冲通道,可以非常灵活地控制协程间的数据流和同步。
- BlockingQueue 是一个接口,Java 提供了多种实现(如 ArrayBlockingQueue, LinkedBlockingQueue),主要通过阻塞操作来实现生产者和消费者之间的同步。
-
用途和应用场景:
- Channel 通常用于协程间的信号传递和数据交换,特别是在需要控制并发操作顺序时。
- BlockingQueue 通常用于处理较大的数据流或者在多线程环境下缓存数据。
共享内存并发 vs. Channel 并发¶
共享内存并发¶
- 适用场景:适合复杂的数据结构共享,或者当有多个线程需要访问和修改同一数据时。在多核处理器上,这种方式可以有效利用缓存一致性协议。
- 优点:可以实现细粒度的控制,对于某些高性能计算场景可以更直接地管理内存。
- 缺点:容易产生竞态条件,编程模型更加复杂,需要精确地控制锁和同步。
Channel 并发¶
- 适用场景:适合事件驱动或消息驱动的应用,如网络服务或并行数据处理。在这些场景中,通信模式清晰,各部分之间的解耦更彻底。
- 优点:简化了并发和同步的管理,代码通常更易于理解和维护。
- 缺点:在极端的高性能需求下,可能会因为消息传递的开销而不如直接的内存访问高效。
选择建议¶
- 如果问题适合通过明确的消息传递进行模块化设计,或者当系统的可维护性和清晰的并发模型比原始性能更重要时,使用 Channel。
- 如果需要最大限度地控制性能,并且可以管理更复杂的同步策略和竞态风险,使用共享内存可能更合适。
在实际开发中,选择合适的并发策略依赖于具体问题、性能需求和团队的熟悉度。对于维护性和开发效率有较高要求的项目,Channel 往往是一个更易于管理的选择。
资料¶
Ended: 开发语言
系统设计 ↵
系统设计¶
缓存系统
概览¶
缓存三大问题¶
缓存雪崩¶
缓存雪崩是指缓存中数据大批量到过期时间,而查询数据量巨大,引起数据库压力过大甚至down机。和缓存击穿不同的是, 缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。
解决办法:
- 缓存过期时间设置成随机
- 热点数据考虑永不过期(定时刷新)
- 使用分布式缓存,防止单点故障缓存全部丢失
缓存穿透¶
缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求,如发起为id为“-1”的数据或id为特别大不存在的数据。这时的用户很可能是攻击者,攻击会导致数据库压力过大。
解决办法:
- 空对象
- 布隆过滤器
缓存击穿¶
缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力
解决办法:
- 热点数据永不过期(后台进程定时刷新)
- 加互斥锁
缓存淘汰策略¶
-
先进先出策略 FIFO(First In,First Out)
如果一个数据最先进入缓存中,则应该最早淘汰掉
-
最近最少使用策略 LRU(Least Recently Used)
如果数据最近被访问过,那么将来被访问的几率也更高。对于循环出现的数据,缓存命中不高。实际实现时候一般可采用双向链表,将最近访问过得缓存key放在链表首部,删除尾部的缓存key,再加上hash表来记录key-value,实现快速访问缓存
-
最少使用策略 LFU(Least Frequently Used)
如果一个数据在最近一段时间内使用次数很少,那么在将来一段时间内被使用的可能性也很小。对于交替出现的数据,缓存命中不高
基于链表实现 LRU 缓存淘汰算法¶
维护一个有序单链表,越靠近链表尾部的结点是越早之前访问的。当有一个新的数据被访问时,我们从链表头开始顺序遍历链表。
如果此数据之前已经被缓存在链表中了,我们遍历得到这个数据对应的结点,并将其从原来的位置删除,然后再插入到链表的头部。
如果此数据没有在缓存链表中,又可以分为两种情况:
如果此时缓存未满,则将此结点直接插入到链表的头部; 如果此时缓存已满,则链表尾结点删除,将新的数据结点插入链表的头部。 现在我们来看下 m 缓存访问的时间复杂度是多少。因为不管缓存有没有满,我们都需要遍历一遍链表,所以这种基于链表的实现思路,缓存访问的时间复杂度为 O(n)。
实际上,我们可以继续优化这个实现思路,比如引入散列表(Hash table)来记录每个数据的位置,将缓存访问的时间复杂度降到 O(1)
缓存一致性¶
对于读是不存在缓存与数据库不一致的的情况。读的流程:
- 如果我们的数据在缓存里边有,那么就直接取缓存的。
- 如果缓存里没有我们想要的数据,我们会先去查询数据库,然后将数据库查出来的数据写到缓存中。
- 最后将数据返回给请求
对于数据库更新操作, 执行操作时候,两种选择:
- 先操作数据库,再操作缓存
- 先操作缓存,再操作数据库
操作缓存,两种方案选择:
- 更新缓存
- 删除缓存
一般我们都是采取删除缓存缓存策略的, 原因:
- 高并发环境下,无论是先操作数据库还是后操作数据库而言,如果加上更新缓存,那就更加容易导致数据库与缓存数据不一致问题。(删除缓存直接和简单很多)
- 如果每次更新了数据库,都要更新缓存【这里指的是频繁更新的场景,这会耗费一定的性能】,倒不如直接删除掉。等再次读取时,缓存里没有,那我到数据库找,在数据库找到再写到缓存里边 (体现懒加载)
先删缓存,再更新数据库
该方案会导致不一致的原因是。同时有一个请求A进行更新操作,另一个请求B进行查询操作。那么会出现如下情形: (1)请求A进行写操作,删除缓存 (2)请求B查询发现缓存不存在 (3)请求B去数据库查询得到旧值 (4)请求B将旧值写入缓存 (5)请求A将新值写入数据库 上述情况就会导致不一致的情形出现。而且,如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。
先更新数据,再删除缓存:
如果在高并发的场景下,出现数据库与缓存数据不一致的概率特别低,也不是没有:
- 缓存刚好失效
- 线程 A 查询数据库,得一个旧值
- 线程 B 将新值写入数据库
- 线程 B 删除缓存
- 线程 A 将查到的旧值写入缓存
解决办法:
- Cache-Aside模式
什么是Cache-Aside模式?
Cache-Aside(旁路缓存)模式 是一种缓存策略,当缓存未命中时从数据库加载数据并更新缓存,以提高读取效率,同时在数据更新时同步更新缓存和数据库以保持一致性。
- binlog模式
资料¶
- 原文地址:github.com/donnemartin/system-design-primer
- 译文出自:掘金翻译计划
- 译者:XatMassacrE、L9m、Airmacho、xiaoyusilen、jifaxu、根号三
- 这个 链接 用来查看本翻译与英文版是否有差别(如果你没有看到 README.md 发生变化,那就意味着这份翻译文档是最新的)。
English ∙ 日本語 ∙ 简体中文 ∙ 繁體中文 | العَرَبِيَّة ∙ বাংলা ∙ Português do Brasil ∙ Deutsch ∙ ελληνικά ∙ עברית ∙ Italiano ∙ 한국어 ∙ فارسی ∙ Polski ∙ русский язык ∙ Español ∙ ภาษาไทย ∙ Türkçe ∙ tiếng Việt ∙ Français | Add Translation
系统设计入门¶
目的¶
学习如何设计大型系统。
为系统设计的面试做准备。
学习如何设计大型系统¶
学习如何设计可扩展的系统将会有助于你成为一个更好的工程师。
系统设计是一个很宽泛的话题。在互联网上,关于系统设计原则的资源也是多如牛毛。
这个仓库就是这些资源的**组织收集**,它可以帮助你学习如何构建可扩展的系统。
从开源社区学习¶
这是一个不断更新的开源项目的初期的版本。
欢迎贡献!
为系统设计的面试做准备¶
在很多科技公司中,除了代码面试,系统设计也是**技术面试过程**中的一个**必要环节**。
实践常见的系统设计面试题**并且把你的答案和**例子的解答**进行**对照:讨论,代码和图表。
面试准备的其他主题:
抽认卡¶
这里提供的抽认卡堆使用间隔重复的方法,帮助你记忆关键的系统设计概念。
随时随地都可使用。
代码资源:互动式编程挑战¶
你正在寻找资源以准备编程面试吗?
请查看我们的姐妹仓库互动式编程挑战,其中包含了一个额外的抽认卡堆:
贡献¶
从社区中学习。
欢迎提交 PR 提供帮助:
- 修复错误
- 完善章节
- 添加章节
- 帮助翻译
一些还需要完善的内容放在了正在完善中。
请查看贡献指南。
系统设计主题的索引¶
各种系统设计主题的摘要,包括优点和缺点。每一个主题都面临着取舍和权衡。
每个章节都包含着更多的资源的链接。
- 系统设计主题:从这里开始
- 性能与拓展性
- 延迟与吞吐量
- 可用性与一致性
- 一致模式
- 可用模式
- 域名系统
- CDN
- 负载均衡器
- 反向代理(web 服务器)
- 应用层
- 数据库
- 缓存
- 异步
- 通讯
- 安全
- 附录
- 正在完善中
- 致谢
- 联系方式
- 许可
学习指引¶
基于你面试的时间线(短、中、长)去复习那些推荐的主题。
问:对于面试来说,我需要知道这里的所有知识点吗?
答:不,如果只是为了准备面试的话,你并不需要知道所有的知识点。
在一场面试中你会被问到什么取决于下面这些因素:
- 你的经验
- 你的技术背景
- 你面试的职位
- 你面试的公司
- 运气
那些有经验的候选人通常会被期望了解更多的系统设计的知识。架构师或者团队负责人则会被期望了解更多除了个人贡献之外的知识。顶级的科技公司通常也会有一次或者更多的系统设计面试。
面试会很宽泛的展开并在几个领域深入。这会帮助你了解一些关于系统设计的不同的主题。基于你的时间线,经验,面试的职位和面试的公司对下面的指导做出适当的调整。
- 短期 - 以系统设计主题的**广度**为目标。通过解决**一些**面试题来练习。
- 中期 - 以系统设计主题的**广度**和**初级深度**为目标。通过解决**很多**面试题来练习。
- 长期 - 以系统设计主题的**广度**和**高级深度**为目标。通过解决**大部分**面试题来练习。
| 短期 | 中期 | 长期 | |
|---|---|---|---|
| 阅读 系统设计主题 以获得一个关于系统如何工作的宽泛的认识 | |||
| 阅读一些你要面试的公司工程博客的文章 | |||
| 阅读 真实架构 | |||
| 复习 如何处理一个系统设计面试题 | |||
| 完成 系统设计的面试题和解答 | 一些 | 很多 | 大部分 |
| 完成 面向对象设计的面试题和解答 | 一些 | 很多 | 大部分 |
| 复习 其它的系统设计面试题 | 一些 | 很多 | 大部分 |
如何处理一个系统设计的面试题¶
系统设计面试是一个**开放式的对话**。他们期望你去主导这个对话。
你可以使用下面的步骤来指引讨论。为了巩固这个过程,请使用下面的步骤完成系统设计的面试题和解答这个章节。
第一步:描述使用场景,约束和假设¶
把所有需要的东西聚集在一起,审视问题。不停的提问,以至于我们可以明确使用场景和约束。讨论假设。
- 谁会使用它?
- 他们会怎样使用它?
- 有多少用户?
- 系统的作用是什么?
- 系统的输入输出分别是什么?
- 我们希望处理多少数据?
- 我们希望每秒钟处理多少请求?
- 我们希望的读写比率?
第二步:创造一个高层级的设计¶
使用所有重要的组件来描绘出一个高层级的设计。
- 画出主要的组件和连接
- 证明你的想法
第三步:设计核心组件¶
对每一个核心组件进行详细深入的分析。举例来说,如果你被问到设计一个 url 缩写服务,开始讨论:
- 生成并储存一个完整 url 的 hash
- 将一个 hashed url 翻译成完整的 url
- 数据库查找
- API 和面向对象设计
第四步:扩展设计¶
确认和处理瓶颈以及一些限制。举例来说就是你需要下面的这些来完成扩展性的议题吗?
- 负载均衡
- 水平扩展
- 缓存
- 数据库分片
论述可能的解决办法和代价。每件事情需要取舍。可以使用可扩展系统的设计原则来处理瓶颈。
预估计算量¶
你或许会被要求通过手算进行一些估算。附录涉及到的是下面的这些资源:
相关资源和延伸阅读¶
查看下面的链接以获得我们期望的更好的想法:
系统设计的面试题和解答¶
普通的系统设计面试题和相关事例的论述,代码和图表。
与内容有关的解答在
solutions/文件夹中。
| 问题 | |
|---|---|
| 设计 Pastebin.com (或者 Bit.ly) | 解答 |
| 设计 Twitter 时间线和搜索 (或者 Facebook feed 和搜索) | 解答 |
| 设计一个网页爬虫 | 解答 |
| 设计 Mint.com | 解答 |
| 为一个社交网络设计数据结构 | 解答 |
| 为搜索引擎设计一个 key-value 储存 | 解答 |
| 通过分类特性设计 Amazon 的销售排名 | 解答 |
| 在 AWS 上设计一个百万用户级别的系统 | 解答 |
| 添加一个系统设计问题 | 贡献 |
设计 Pastebin.com (或者 Bit.ly)¶
设计 Twitter 时间线和搜索 (或者 Facebook feed 和搜索)¶
设计一个网页爬虫¶
设计 Mint.com¶
为一个社交网络设计数据结构¶
为搜索引擎设计一个 key-value 储存¶
设计按类别分类的 Amazon 销售排名¶
在 AWS 上设计一个百万用户级别的系统¶
面向对象设计的面试问题及解答¶
常见面向对象设计面试问题及实例讨论,代码和图表演示。
与内容相关的解决方案在
solutions/文件夹中。注:此节还在完善中
| 问题 | |
|---|---|
| 设计 hash map | 解决方案 |
| 设计 LRU 缓存 | 解决方案 |
| 设计一个呼叫中心 | 解决方案 |
| 设计一副牌 | 解决方案 |
| 设计一个停车场 | 解决方案 |
| 设计一个聊天服务 | 解决方案 |
| 设计一个环形数组 | 待解决 |
| 添加一个面向对象设计问题 | 待解决 |
系统设计主题:从这里开始¶
不熟悉系统设计?
首先,你需要对一般性原则有一个基本的认识,知道它们是什么,怎样使用以及利弊。
第一步:回顾可扩展性(scalability)的视频讲座¶
- 主题涵盖
- 垂直扩展(Vertical scaling)
- 水平扩展(Horizontal scaling)
- 缓存
- 负载均衡
- 数据库复制
- 数据库分区
第二步:回顾可扩展性文章¶
接下来的步骤¶
接下来,我们将看看高阶的权衡和取舍:
- 性能**与**可扩展性
- 延迟**与**吞吐量
- 可用性**与**一致性
记住**每个方面都面临取舍和权衡**。
然后,我们将深入更具体的主题,如 DNS、CDN 和负载均衡器。
性能与可扩展性¶
如果服务**性能**的增长与资源的增加是成比例的,服务就是可扩展的。通常,提高性能意味着服务于更多的工作单元,另一方面,当数据集增长时,同样也可以处理更大的工作单位。1
另一个角度来看待性能与可扩展性:
- 如果你的系统有**性能**问题,对于单个用户来说是缓慢的。
- 如果你的系统有**可扩展性**问题,单个用户较快但在高负载下会变慢。
来源及延伸阅读¶
延迟与吞吐量¶
**延迟**是执行操作或运算结果所花费的时间。
**吞吐量**是单位时间内(执行)此类操作或运算的数量。
通常,你应该以**可接受级延迟**下**最大化吞吐量**为目标。
来源及延伸阅读¶
可用性与一致性¶
CAP 理论¶
在一个分布式计算系统中,只能同时满足下列的两点:
- 一致性 ─ 每次访问都能获得最新数据但可能会收到错误响应
- 可用性 ─ 每次访问都能收到非错响应,但不保证获取到最新数据
- 分区容错性 ─ 在任意分区网络故障的情况下系统仍能继续运行
网络并不可靠,所以你应要支持分区容错性,并需要在软件可用性和一致性间做出取舍。
CP ─ 一致性和分区容错性¶
等待分区节点的响应可能会导致延时错误。如果你的业务需求需要原子读写,CP 是一个不错的选择。
AP ─ 可用性与分区容错性¶
响应节点上可用数据的最近版本可能并不是最新的。当分区解析完后,写入(操作)可能需要一些时间来传播。
如果业务需求允许最终一致性,或当有外部故障时要求系统继续运行,AP 是一个不错的选择。
来源及延伸阅读¶
一致性模式¶
有同一份数据的多份副本,我们面临着怎样同步它们的选择,以便让客户端有一致的显示数据。回想 CAP 理论中的一致性定义 ─ 每次访问都能获得最新数据但可能会收到错误响应
弱一致性¶
在写入之后,访问可能看到,也可能看不到(写入数据)。尽力优化之让其能访问最新数据。
这种方式可以 memcached 等系统中看到。弱一致性在 VoIP,视频聊天和实时多人游戏等真实用例中表现不错。打个比方,如果你在通话中丢失信号几秒钟时间,当重新连接时你是听不到这几秒钟所说的话的。
最终一致性¶
在写入后,访问最终能看到写入数据(通常在数毫秒内)。数据被异步复制。
DNS 和 email 等系统使用的是此种方式。最终一致性在高可用性系统中效果不错。
强一致性¶
在写入后,访问立即可见。数据被同步复制。
文件系统和关系型数据库(RDBMS)中使用的是此种方式。强一致性在需要记录的系统中运作良好。
来源及延伸阅读¶
可用性模式¶
有两种支持高可用性的模式: 故障切换(fail-over)**和**复制(replication)。
故障切换¶
工作到备用切换(Active-passive)¶
关于工作到备用的故障切换流程是,工作服务器发送周期信号给待机中的备用服务器。如果周期信号中断,备用服务器切换成工作服务器的 IP 地址并恢复服务。
宕机时间取决于备用服务器处于“热”待机状态还是需要从“冷”待机状态进行启动。只有工作服务器处理流量。
工作到备用的故障切换也被称为主从切换。
双工作切换(Active-active)¶
在双工作切换中,双方都在管控流量,在它们之间分散负载。
如果是外网服务器,DNS 将需要对两方都了解。如果是内网服务器,应用程序逻辑将需要对两方都了解。
双工作切换也可以称为主主切换。
缺陷:故障切换¶
- 故障切换需要添加额外硬件并增加复杂性。
- 如果新写入数据在能被复制到备用系统之前,工作系统出现了故障,则有可能会丢失数据。
复制¶
主─从复制和主─主复制¶
这个主题进一步探讨了数据库部分:
域名系统¶
域名系统是把 www.example.com 等域名转换成 IP 地址。
域名系统是分层次的,一些 DNS 服务器位于顶层。当查询(域名) IP 时,路由或 ISP 提供连接 DNS 服务器的信息。较底层的 DNS 服务器缓存映射,它可能会因为 DNS 传播延时而失效。DNS 结果可以缓存在浏览器或操作系统中一段时间,时间长短取决于存活时间 TTL。
- NS 记录(域名服务) ─ 指定解析域名或子域名的 DNS 服务器。
- MX 记录(邮件交换) ─ 指定接收信息的邮件服务器。
- A 记录(地址) ─ 指定域名对应的 IP 地址记录。
- CNAME(规范) ─ 一个域名映射到另一个域名或
CNAME记录( example.com 指向 www.example.com )或映射到一个A记录。
CloudFlare 和 Route 53 等平台提供管理 DNS 的功能。某些 DNS 服务通过集中方式来路由流量:
- 加权轮询调度
- 防止流量进入维护中的服务器
- 在不同大小集群间负载均衡
- A/B 测试
- 基于延迟路由
- 基于地理位置路由
缺陷:DNS¶
- 虽说缓存可以减轻 DNS 延迟,但连接 DNS 服务器还是带来了轻微的延迟。
- 虽然它们通常由政府,网络服务提供商和大公司管理,但 DNS 服务管理仍可能是复杂的。
- DNS 服务最近遭受 DDoS 攻击,阻止不知道 Twitter IP 地址的用户访问 Twitter。
来源及延伸阅读¶
内容分发网络(CDN)¶
内容分发网络(CDN)是一个全球性的代理服务器分布式网络,它从靠近用户的位置提供内容。通常,HTML/CSS/JS,图片和视频等静态内容由 CDN 提供,虽然亚马逊 CloudFront 等也支持动态内容。CDN 的 DNS 解析会告知客户端连接哪台服务器。
将内容存储在 CDN 上可以从两个方面来提供性能:
- 从靠近用户的数据中心提供资源
- 通过 CDN 你的服务器不必真的处理请求
CDN 推送(push)¶
当你服务器上内容发生变动时,推送 CDN 接受新内容。直接推送给 CDN 并重写 URL 地址以指向你的内容的 CDN 地址。你可以配置内容到期时间及何时更新。内容只有在更改或新增是才推送,流量最小化,但储存最大化。
CDN 拉取(pull)¶
CDN 拉取是当第一个用户请求该资源时,从服务器上拉取资源。你将内容留在自己的服务器上并重写 URL 指向 CDN 地址。直到内容被缓存在 CDN 上为止,这样请求只会更慢,
存活时间(TTL)决定缓存多久时间。CDN 拉取方式最小化 CDN 上的储存空间,但如果过期文件并在实际更改之前被拉取,则会导致冗余的流量。
高流量站点使用 CDN 拉取效果不错,因为只有最近请求的内容保存在 CDN 中,流量才能更平衡地分散。
缺陷:CDN¶
- CDN 成本可能因流量而异,可能在权衡之后你将不会使用 CDN。
- 如果在 TTL 过期之前更新内容,CDN 缓存内容可能会过时。
- CDN 需要更改静态内容的 URL 地址以指向 CDN。
来源及延伸阅读¶
负载均衡器¶
负载均衡器将传入的请求分发到应用服务器和数据库等计算资源。无论哪种情况,负载均衡器将从计算资源来的响应返回给恰当的客户端。负载均衡器的效用在于:
- 防止请求进入不好的服务器
- 防止资源过载
- 帮助消除单一的故障点
负载均衡器可以通过硬件(昂贵)或 HAProxy 等软件来实现。 增加的好处包括:
- SSL 终结 ─ 解密传入的请求并加密服务器响应,这样的话后端服务器就不必再执行这些潜在高消耗运算了。
- 不需要再每台服务器上安装 X.509 证书。
- Session 留存 ─ 如果 Web 应用程序不追踪会话,发出 cookie 并将特定客户端的请求路由到同一实例。
通常会设置采用工作─备用 或 双工作 模式的多个负载均衡器,以免发生故障。
负载均衡器能基于多种方式来路由流量:
- 随机
- 最少负载
- Session/cookie
- 轮询调度或加权轮询调度算法
- 四层负载均衡
- 七层负载均衡
四层负载均衡¶
四层负载均衡根据监看传输层的信息来决定如何分发请求。通常,这会涉及来源,目标 IP 地址和请求头中的端口,但不包括数据包(报文)内容。四层负载均衡执行网络地址转换(NAT)来向上游服务器转发网络数据包。
七层负载均衡器¶
七层负载均衡器根据监控应用层来决定怎样分发请求。这会涉及请求头的内容,消息和 cookie。七层负载均衡器终结网络流量,读取消息,做出负载均衡判定,然后传送给特定服务器。比如,一个七层负载均衡器能直接将视频流量连接到托管视频的服务器,同时将更敏感的用户账单流量引导到安全性更强的服务器。
以损失灵活性为代价,四层负载均衡比七层负载均衡花费更少时间和计算资源,虽然这对现代商用硬件的性能影响甚微。
水平扩展¶
负载均衡器还能帮助水平扩展,提高性能和可用性。使用商业硬件的性价比更高,并且比在单台硬件上**垂直扩展**更贵的硬件具有更高的可用性。相比招聘特定企业系统人才,招聘商业硬件方面的人才更加容易。
缺陷:水平扩展¶
- 水平扩展引入了复杂度并涉及服务器复制
- 服务器应该是无状态的:它们也不该包含像 session 或资料图片等与用户关联的数据。
- session 可以集中存储在数据库或持久化缓存(Redis、Memcached)的数据存储区中。
- 缓存和数据库等下游服务器需要随着上游服务器进行扩展,以处理更多的并发连接。
缺陷:负载均衡器¶
- 如果没有足够的资源配置或配置错误,负载均衡器会变成一个性能瓶颈。
- 引入负载均衡器以帮助消除单点故障但导致了额外的复杂性。
- 单个负载均衡器会导致单点故障,但配置多个负载均衡器会进一步增加复杂性。
来源及延伸阅读¶
反向代理(web 服务器)¶
反向代理是一种可以集中地调用内部服务,并提供统一接口给公共客户的 web 服务器。来自客户端的请求先被反向代理服务器转发到可响应请求的服务器,然后代理再把服务器的响应结果返回给客户端。
带来的好处包括:
- 增加安全性 - 隐藏后端服务器的信息,屏蔽黑名单中的 IP,限制每个客户端的连接数。
- 提高可扩展性和灵活性 - 客户端只能看到反向代理服务器的 IP,这使你可以增减服务器或者修改它们的配置。
- 本地终结 SSL 会话 - 解密传入请求,加密服务器响应,这样后端服务器就不必完成这些潜在的高成本的操作。
- 免除了在每个服务器上安装 X.509 证书的需要
- 压缩 - 压缩服务器响应
- 缓存 - 直接返回命中的缓存结果
- 静态内容 - 直接提供静态内容
- HTML/CSS/JS
- 图片
- 视频
- 等等
负载均衡器与反向代理¶
- 当你有多个服务器时,部署负载均衡器非常有用。通常,负载均衡器将流量路由给一组功能相同的服务器上。
- 即使只有一台 web 服务器或者应用服务器时,反向代理也有用,可以参考上一节介绍的好处。
- NGINX 和 HAProxy 等解决方案可以同时支持第七层反向代理和负载均衡。
不利之处:反向代理¶
- 引入反向代理会增加系统的复杂度。
- 单独一个反向代理服务器仍可能发生单点故障,配置多台反向代理服务器(如故障转移)会进一步增加复杂度。
来源及延伸阅读¶
应用层¶
将 Web 服务层与应用层(也被称作平台层)分离,可以独立缩放和配置这两层。添加新的 API 只需要添加应用服务器,而不必添加额外的 web 服务器。
**单一职责原则**提倡小型的,自治的服务共同合作。小团队通过提供小型的服务,可以更激进地计划增长。
应用层中的工作进程也有可以实现异步化。
微服务¶
与此讨论相关的话题是 微服务,可以被描述为一系列可以独立部署的小型的,模块化服务。每个服务运行在一个独立的线程中,通过明确定义的轻量级机制通讯,共同实现业务目标。1
例如,Pinterest 可能有这些微服务: 用户资料、关注者、Feed 流、搜索、照片上传等。
服务发现¶
像 Consul,Etcd 和 Zookeeper 这样的系统可以通过追踪注册名、地址、端口等信息来帮助服务互相发现对方。Health checks 可以帮助确认服务的完整性和是否经常使用一个 HTTP 路径。Consul 和 Etcd 都有一个内建的 key-value 存储 用来存储配置信息和其他的共享信息。
不利之处:应用层¶
- 添加由多个松耦合服务组成的应用层,从架构、运营、流程等层面来讲将非常不同(相对于单体系统)。
- 微服务会增加部署和运营的复杂度。
来源及延伸阅读¶
数据库¶
关系型数据库管理系统(RDBMS)¶
像 SQL 这样的关系型数据库是一系列以表的形式组织的数据项集合。
校对注:这里作者 SQL 可能指的是 MySQL
ACID 用来描述关系型数据库事务的特性。
- 原子性 - 每个事务内部所有操作要么全部完成,要么全部不完成。
- 一致性 - 任何事务都使数据库从一个有效的状态转换到另一个有效状态。
- 隔离性 - 并发执行事务的结果与顺序执行事务的结果相同。
- 持久性 - 事务提交后,对系统的影响是永久的。
关系型数据库扩展包括许多技术:主从复制、主主复制、联合、分片、非规范化**和 **SQL调优。
主从复制¶
主库同时负责读取和写入操作,并复制写入到一个或多个从库中,从库只负责读操作。树状形式的从库再将写入复制到更多的从库中去。如果主库离线,系统可以以只读模式运行,直到某个从库被提升为主库或有新的主库出现。
不利之处:主从复制¶
- 将从库提升为主库需要额外的逻辑。
- 参考不利之处:复制中,主从复制和主主复制**共同**的问题。
主主复制¶
两个主库都负责读操作和写操作,写入操作时互相协调。如果其中一个主库挂机,系统可以继续读取和写入。
不利之处: 主主复制¶
- 你需要添加负载均衡器或者在应用逻辑中做改动,来确定写入哪一个数据库。
- 多数主-主系统要么不能保证一致性(违反 ACID),要么因为同步产生了写入延迟。
- 随着更多写入节点的加入和延迟的提高,如何解决冲突显得越发重要。
- 参考不利之处:复制中,主从复制和主主复制**共同**的问题。
不利之处:复制¶
- 如果主库在将新写入的数据复制到其他节点前挂掉,则有数据丢失的可能。
- 写入会被重放到负责读取操作的副本。副本可能因为过多写操作阻塞住,导致读取功能异常。
- 读取从库越多,需要复制的写入数据就越多,导致更严重的复制延迟。
- 在某些数据库系统中,写入主库的操作可以用多个线程并行写入,但读取副本只支持单线程顺序地写入。
- 复制意味着更多的硬件和额外的复杂度。
来源及延伸阅读¶
联合¶
联合(或按功能划分)将数据库按对应功能分割。例如,你可以有三个数据库:论坛、用户**和**产品,而不仅是一个单体数据库,从而减少每个数据库的读取和写入流量,减少复制延迟。较小的数据库意味着更多适合放入内存的数据,进而意味着更高的缓存命中几率。没有只能串行写入的中心化主库,你可以并行写入,提高负载能力。
不利之处:联合¶
- 如果你的数据库模式需要大量的功能和数据表,联合的效率并不好。
- 你需要更新应用程序的逻辑来确定要读取和写入哪个数据库。
- 用 server link 从两个库联结数据更复杂。
- 联合需要更多的硬件和额外的复杂度。
来源及延伸阅读:联合¶
分片¶
分片将数据分配在不同的数据库上,使得每个数据库仅管理整个数据集的一个子集。以用户数据库为例,随着用户数量的增加,越来越多的分片会被添加到集群中。
类似联合的优点,分片可以减少读取和写入流量,减少复制并提高缓存命中率。也减少了索引,通常意味着查询更快,性能更好。如果一个分片出问题,其他的仍能运行,你可以使用某种形式的冗余来防止数据丢失。类似联合,没有只能串行写入的中心化主库,你可以并行写入,提高负载能力。
常见的做法是用户姓氏的首字母或者用户的地理位置来分隔用户表。
不利之处:分片¶
- 你需要修改应用程序的逻辑来实现分片,这会带来复杂的 SQL 查询。
- 分片不合理可能导致数据负载不均衡。例如,被频繁访问的用户数据会导致其所在分片的负载相对其他分片高。
- 再平衡会引入额外的复杂度。基于一致性哈希的分片算法可以减少这种情况。
- 联结多个分片的数据操作更复杂。
- 分片需要更多的硬件和额外的复杂度。
来源及延伸阅读:分片¶
非规范化¶
非规范化试图以写入性能为代价来换取读取性能。在多个表中冗余数据副本,以避免高成本的联结操作。一些关系型数据库,比如 PostgreSQL 和 Oracle 支持物化视图,可以处理冗余信息存储和保证冗余副本一致。
当数据使用诸如联合和分片等技术被分割,进一步提高了处理跨数据中心的联结操作复杂度。非规范化可以规避这种复杂的联结操作。
在多数系统中,读取操作的频率远高于写入操作,比例可达到 100:1,甚至 1000:1。需要复杂的数据库联结的读取操作成本非常高,在磁盘操作上消耗了大量时间。
不利之处:非规范化¶
- 数据会冗余。
- 约束可以帮助冗余的信息副本保持同步,但这样会增加数据库设计的复杂度。
- 非规范化的数据库在高写入负载下性能可能比规范化的数据库差。
来源及延伸阅读:非规范化¶
SQL 调优¶
SQL 调优是一个范围很广的话题,有很多相关的书可以作为参考。
利用**基准测试**和**性能分析**来模拟和发现系统瓶颈很重要。
基准测试和性能分析可能会指引你到以下优化方案。
改进模式¶
- 为了实现快速访问,MySQL 在磁盘上用连续的块存储数据。
- 使用
CHAR类型存储固定长度的字段,不要用VARCHAR。 CHAR在快速、随机访问时效率很高。如果使用VARCHAR,如果你想读取下一个字符串,不得不先读取到当前字符串的末尾。- 使用
TEXT类型存储大块的文本,例如博客正文。TEXT还允许布尔搜索。使用TEXT字段需要在磁盘上存储一个用于定位文本块的指针。 - 使用
INT类型存储高达 2^32 或 40 亿的较大数字。 - 使用
DECIMAL类型存储货币可以避免浮点数表示错误。 - 避免使用
BLOBS存储实际对象,而是用来存储存放对象的位置。 VARCHAR(255)是以 8 位数字存储的最大字符数,在某些关系型数据库中,最大限度地利用字节。- 在适用场景中设置
NOT NULL约束来提高搜索性能。
使用正确的索引¶
- 你正查询(
SELECT、GROUP BY、ORDER BY、JOIN)的列如果用了索引会更快。 - 索引通常表示为自平衡的 B 树,可以保持数据有序,并允许在对数时间内进行搜索,顺序访问,插入,删除操作。
- 设置索引,会将数据存在内存中,占用了更多内存空间。
- 写入操作会变慢,因为索引需要被更新。
- 加载大量数据时,禁用索引再加载数据,然后重建索引,这样也许会更快。
避免高成本的联结操作¶
- 有性能需要,可以进行非规范化。
分割数据表¶
- 将热点数据拆分到单独的数据表中,可以有助于缓存。
调优查询缓存¶
来源及延伸阅读¶
NoSQL¶
NoSQL 是**键-值数据库**、文档型数据库、**列型数据库**或**图数据库**的统称。数据库是非规范化的,表联结大多在应用程序代码中完成。大多数 NoSQL 无法实现真正符合 ACID 的事务,支持最终一致。
BASE 通常被用于描述 NoSQL 数据库的特性。相比 CAP 理论,BASE 强调可用性超过一致性。
- 基本可用 - 系统保证可用性。
- 软状态 - 即使没有输入,系统状态也可能随着时间变化。
- 最终一致性 - 经过一段时间之后,系统最终会变一致,因为系统在此期间没有收到任何输入。
除了在 SQL 还是 NoSQL 之间做选择,了解哪种类型的 NoSQL 数据库最适合你的用例也是非常有帮助的。我们将在下一节中快速了解下 键-值存储、文档型存储、**列型存储**和**图存储**数据库。
键-值存储¶
抽象模型:哈希表
键-值存储通常可以实现 O(1) 时间读写,用内存或 SSD 存储数据。数据存储可以按字典顺序维护键,从而实现键的高效检索。键-值存储可以用于存储元数据。
键-值存储性能很高,通常用于存储简单数据模型或频繁修改的数据,如存放在内存中的缓存。键-值存储提供的操作有限,如果需要更多操作,复杂度将转嫁到应用程序层面。
键-值存储是如文档存储,在某些情况下,甚至是图存储等更复杂的存储系统的基础。
来源及延伸阅读¶
文档类型存储¶
抽象模型:将文档作为值的键-值存储
文档类型存储以文档(XML、JSON、二进制文件等)为中心,文档存储了指定对象的全部信息。文档存储根据文档自身的内部结构提供 API 或查询语句来实现查询。请注意,许多键-值存储数据库有用值存储元数据的特性,这也模糊了这两种存储类型的界限。
基于底层实现,文档可以根据集合、标签、元数据或者文件夹组织。尽管不同文档可以被组织在一起或者分成一组,但相互之间可能具有完全不同的字段。
MongoDB 和 CouchDB 等一些文档类型存储还提供了类似 SQL 语言的查询语句来实现复杂查询。DynamoDB 同时支持键-值存储和文档类型存储。
文档类型存储具备高度的灵活性,常用于处理偶尔变化的数据。
来源及延伸阅读:文档类型存储¶
列型存储¶
抽象模型:嵌套的
ColumnFamily<RowKey, Columns<ColKey, Value, Timestamp>>映射
类型存储的基本数据单元是列(名/值对)。列可以在列族(类似于 SQL 的数据表)中被分组。超级列族再分组普通列族。你可以使用行键独立访问每一列,具有相同行键值的列组成一行。每个值都包含版本的时间戳用于解决版本冲突。
Google 发布了第一个列型存储数据库 Bigtable,它影响了 Hadoop 生态系统中活跃的开源数据库 HBase 和 Facebook 的 Cassandra。像 BigTable,HBase 和 Cassandra 这样的存储系统将键以字母顺序存储,可以高效地读取键列。
列型存储具备高可用性和高可扩展性。通常被用于大数据相关存储。
来源及延伸阅读:列型存储¶
图数据库¶
抽象模型: 图
在图数据库中,一个节点对应一条记录,一个弧对应两个节点之间的关系。图数据库被优化用于表示外键繁多的复杂关系或多对多关系。
图数据库为存储复杂关系的数据模型,如社交网络,提供了很高的性能。它们相对较新,尚未广泛应用,查找开发工具或者资源相对较难。许多图只能通过 REST API 访问。
相关资源和延伸阅读:图¶
来源及延伸阅读:NoSQL¶
SQL 还是 NoSQL¶
选取 SQL 的原因:
- 结构化数据
- 严格的模式
- 关系型数据
- 需要复杂的联结操作
- 事务
- 清晰的扩展模式
- 既有资源更丰富:开发者、社区、代码库、工具等
- 通过索引进行查询非常快
选取 NoSQL 的原因:
- 半结构化数据
- 动态或灵活的模式
- 非关系型数据
- 不需要复杂的联结操作
- 存储 TB (甚至 PB)级别的数据
- 高数据密集的工作负载
- IOPS 高吞吐量
适合 NoSQL 的示例数据:
- 埋点数据和日志数据
- 排行榜或者得分数据
- 临时数据,如购物车
- 频繁访问的(“热”)表
- 元数据/查找表
来源及延伸阅读:SQL 或 NoSQL¶
缓存¶
缓存可以提高页面加载速度,并可以减少服务器和数据库的负载。在这个模型中,分发器先查看请求之前是否被响应过,如果有则将之前的结果直接返回,来省掉真正的处理。
数据库分片均匀分布的读取是最好的。但是热门数据会让读取分布不均匀,这样就会造成瓶颈,如果在数据库前加个缓存,就会抹平不均匀的负载和突发流量对数据库的影响。
客户端缓存¶
缓存可以位于客户端(操作系统或者浏览器),服务端或者不同的缓存层。
CDN 缓存¶
CDN 也被视为一种缓存。
Web 服务器缓存¶
反向代理和缓存(比如 Varnish)可以直接提供静态和动态内容。Web 服务器同样也可以缓存请求,返回相应结果而不必连接应用服务器。
数据库缓存¶
数据库的默认配置中通常包含缓存级别,针对一般用例进行了优化。调整配置,在不同情况下使用不同的模式可以进一步提高性能。
应用缓存¶
基于内存的缓存比如 Memcached 和 Redis 是应用程序和数据存储之间的一种键值存储。由于数据保存在 RAM 中,它比存储在磁盘上的典型数据库要快多了。RAM 比磁盘限制更多,所以例如 least recently used (LRU) 的缓存无效算法可以将「热门数据」放在 RAM 中,而对一些比较「冷门」的数据不做处理。
Redis 有下列附加功能:
- 持久性选项
- 内置数据结构比如有序集合和列表
有多个缓存级别,分为两大类:数据库查询**和**对象:
- 行级别
- 查询级别
- 完整的可序列化对象
- 完全渲染的 HTML
一般来说,你应该尽量避免基于文件的缓存,因为这使得复制和自动缩放很困难。
数据库查询级别的缓存¶
当你查询数据库的时候,将查询语句的哈希值与查询结果存储到缓存中。这种方法会遇到以下问题:
- 很难用复杂的查询删除已缓存结果。
- 如果一条数据比如表中某条数据的一项被改变,则需要删除所有可能包含已更改项的缓存结果。
对象级别的缓存¶
将您的数据视为对象,就像对待你的应用代码一样。让应用程序将数据从数据库中组合到类实例或数据结构中:
- 如果对象的基础数据已经更改了,那么从缓存中删掉这个对象。
- 允许异步处理:workers 通过使用最新的缓存对象来组装对象。
建议缓存的内容:
- 用户会话
- 完全渲染的 Web 页面
- 活动流
- 用户图数据
何时更新缓存¶
由于你只能在缓存中存储有限的数据,所以你需要选择一个适用于你用例的缓存更新策略。
缓存模式¶
应用从存储器读写。缓存不和存储器直接交互,应用执行以下操作:
- 在缓存中查找记录,如果所需数据不在缓存中
- 从数据库中加载所需内容
- 将查找到的结果存储到缓存中
- 返回所需内容
def get_user(self, user_id):
user = cache.get("user.{0}", user_id)
if user is None:
user = db.query("SELECT * FROM users WHERE user_id = {0}", user_id)
if user is not None:
key = "user.{0}".format(user_id)
cache.set(key, json.dumps(user))
return user
Memcached 通常用这种方式使用。
添加到缓存中的数据读取速度很快。缓存模式也称为延迟加载。只缓存所请求的数据,这避免了没有被请求的数据占满了缓存空间。
缓存的缺点:¶
- 请求的数据如果不在缓存中就需要经过三个步骤来获取数据,这会导致明显的延迟。
- 如果数据库中的数据更新了会导致缓存中的数据过时。这个问题需要通过设置 TTL 强制更新缓存或者直写模式来缓解这种情况。
- 当一个节点出现故障的时候,它将会被一个新的节点替代,这增加了延迟的时间。
直写模式¶
应用使用缓存作为主要的数据存储,将数据读写到缓存中,而缓存负责从数据库中读写数据。
- 应用向缓存中添加/更新数据
- 缓存同步地写入数据存储
- 返回所需内容
应用代码:
缓存代码:
def set_user(user_id, values):
user = db.query("UPDATE Users WHERE id = {0}", user_id, values)
cache.set(user_id, user)
由于存写操作所以直写模式整体是一种很慢的操作,但是读取刚写入的数据很快。相比读取数据,用户通常比较能接受更新数据时速度较慢。缓存中的数据不会过时。
直写模式的缺点:¶
- 由于故障或者缩放而创建的新的节点,新的节点不会缓存,直到数据库更新为止。缓存应用直写模式可以缓解这个问题。
- 写入的大多数数据可能永远都不会被读取,用 TTL 可以最小化这种情况的出现。
回写模式¶
在回写模式中,应用执行以下操作:
- 在缓存中增加或者更新条目
- 异步写入数据,提高写入性能。
回写模式的缺点:¶
- 缓存可能在其内容成功存储之前丢失数据。
- 执行直写模式比缓存或者回写模式更复杂。
刷新¶
你可以将缓存配置成在到期之前自动刷新最近访问过的内容。
如果缓存可以准确预测将来可能请求哪些数据,那么刷新可能会导致延迟与读取时间的降低。
刷新的缺点:¶
- 不能准确预测到未来需要用到的数据可能会导致性能不如不使用刷新。
缓存的缺点:¶
- 需要保持缓存和真实数据源之间的一致性,比如数据库根据缓存无效。
- 需要改变应用程序比如增加 Redis 或者 memcached。
- 无效缓存是个难题,什么时候更新缓存是与之相关的复杂问题。
相关资源和延伸阅读¶
异步¶
异步工作流有助于减少那些原本顺序执行的请求时间。它们可以通过提前进行一些耗时的工作来帮助减少请求时间,比如定期汇总数据。
消息队列¶
消息队列接收,保留和传递消息。如果按顺序执行操作太慢的话,你可以使用有以下工作流的消息队列:
- 应用程序将作业发布到队列,然后通知用户作业状态
- 一个 worker 从队列中取出该作业,对其进行处理,然后显示该作业完成
不去阻塞用户操作,作业在后台处理。在此期间,客户端可能会进行一些处理使得看上去像是任务已经完成了。例如,如果要发送一条推文,推文可能会马上出现在你的时间线上,但是可能需要一些时间才能将你的推文推送到你的所有关注者那里去。
Redis 是一个令人满意的简单的消息代理,但是消息有可能会丢失。
RabbitMQ 很受欢迎但是要求你适应「AMQP」协议并且管理你自己的节点。
Amazon SQS 是被托管的,但可能具有高延迟,并且消息可能会被传送两次。
任务队列¶
任务队列接收任务及其相关数据,运行它们,然后传递其结果。 它们可以支持调度,并可用于在后台运行计算密集型作业。
Celery 支持调度,主要是用 Python 开发的。
背压¶
如果队列开始明显增长,那么队列大小可能会超过内存大小,导致高速缓存未命中,磁盘读取,甚至性能更慢。背压可以通过限制队列大小来帮助我们,从而为队列中的作业保持高吞吐率和良好的响应时间。一旦队列填满,客户端将得到服务器忙或者 HTTP 503 状态码,以便稍后重试。客户端可以在稍后时间重试该请求,也许是指数退避。
异步的缺点:¶
- 简单的计算和实时工作流等用例可能更适用于同步操作,因为引入队列可能会增加延迟和复杂性。
相关资源和延伸阅读¶
通讯¶
超文本传输协议(HTTP)¶
HTTP 是一种在客户端和服务器之间编码和传输数据的方法。它是一个请求/响应协议:客户端和服务端针对相关内容和完成状态信息的请求和响应。HTTP 是独立的,允许请求和响应流经许多执行负载均衡,缓存,加密和压缩的中间路由器和服务器。
一个基本的 HTTP 请求由一个动词(方法)和一个资源(端点)组成。 以下是常见的 HTTP 动词:
| 动词 | 描述 | *幂等 | 安全性 | 可缓存 |
|---|---|---|---|---|
| GET | 读取资源 | Yes | Yes | Yes |
| POST | 创建资源或触发处理数据的进程 | No | No | Yes,如果回应包含刷新信息 |
| PUT | 创建或替换资源 | Yes | No | No |
| PATCH | 部分更新资源 | No | No | Yes,如果回应包含刷新信息 |
| DELETE | 删除资源 | Yes | No | No |
多次执行不会产生不同的结果。
HTTP 是依赖于较低级协议(如 TCP 和 UDP)的应用层协议。
来源及延伸阅读:HTTP¶
传输控制协议(TCP)¶
TCP 是通过 IP 网络的面向连接的协议。 使用握手建立和断开连接。 发送的所有数据包保证以原始顺序到达目的地,用以下措施保证数据包不被损坏:
如果发送者没有收到正确的响应,它将重新发送数据包。如果多次超时,连接就会断开。TCP 实行流量控制和拥塞控制。这些确保措施会导致延迟,而且通常导致传输效率比 UDP 低。
为了确保高吞吐量,Web 服务器可以保持大量的 TCP 连接,从而导致高内存使用。在 Web 服务器线程间拥有大量开放连接可能开销巨大,消耗资源过多,也就是说,一个 memcached 服务器。连接池 可以帮助除了在适用的情况下切换到 UDP。
TCP 对于需要高可靠性但时间紧迫的应用程序很有用。比如包括 Web 服务器,数据库信息,SMTP,FTP 和 SSH。
以下情况使用 TCP 代替 UDP:
- 你需要数据完好无损。
- 你想对网络吞吐量自动进行最佳评估。
用户数据报协议(UDP)¶
UDP 是无连接的。数据报(类似于数据包)只在数据报级别有保证。数据报可能会无序的到达目的地,也有可能会遗失。UDP 不支持拥塞控制。虽然不如 TCP 那样有保证,但 UDP 通常效率更高。
UDP 可以通过广播将数据报发送至子网内的所有设备。这对 DHCP 很有用,因为子网内的设备还没有分配 IP 地址,而 IP 对于 TCP 是必须的。
UDP 可靠性更低但适合用在网络电话、视频聊天,流媒体和实时多人游戏上。
以下情况使用 UDP 代替 TCP:
- 你需要低延迟
- 相对于数据丢失更糟的是数据延迟
- 你想实现自己的错误校正方法
来源及延伸阅读:TCP 与 UDP¶
远程过程调用协议(RPC)¶
Source: Crack the system design interview
在 RPC 中,客户端会去调用另一个地址空间(通常是一个远程服务器)里的方法。调用代码看起来就像是调用的是一个本地方法,客户端和服务器交互的具体过程被抽象。远程调用相对于本地调用一般较慢而且可靠性更差,因此区分两者是有帮助的。热门的 RPC 框架包括 Protobuf、Thrift 和 Avro。
RPC 是一个“请求-响应”协议:
- 客户端程序 ── 调用客户端存根程序。就像调用本地方法一样,参数会被压入栈中。
- 客户端 stub 程序 ── 将请求过程的 id 和参数打包进请求信息中。
- 客户端通信模块 ── 将信息从客户端发送至服务端。
- 服务端通信模块 ── 将接受的包传给服务端存根程序。
- 服务端 stub 程序 ── 将结果解包,依据过程 id 调用服务端方法并将参数传递过去。
RPC 调用示例:
GET /someoperation?data=anId
POST /anotheroperation
{
"data":"anId";
"anotherdata": "another value"
}
RPC 专注于暴露方法。RPC 通常用于处理内部通讯的性能问题,这样你可以手动处理本地调用以更好的适应你的情况。
当以下情况时选择本地库(也就是 SDK):
- 你知道你的目标平台。
- 你想控制如何访问你的“逻辑”。
- 你想对发生在你的库中的错误进行控制。
- 性能和终端用户体验是你最关心的事。
遵循 REST 的 HTTP API 往往更适用于公共 API。
缺点:RPC¶
- RPC 客户端与服务实现捆绑地很紧密。
- 一个新的 API 必须在每一个操作或者用例中定义。
- RPC 很难调试。
- 你可能没办法很方便的去修改现有的技术。举个例子,如果你希望在 Squid 这样的缓存服务器上确保 RPC 被正确缓存的话可能需要一些额外的努力了。
表述性状态转移(REST)¶
REST 是一种强制的客户端/服务端架构设计模型,客户端基于服务端管理的一系列资源操作。服务端提供修改或获取资源的接口。所有的通信必须是无状态和可缓存的。
RESTful 接口有四条规则:
- 标志资源(HTTP 里的 URI) ── 无论什么操作都使用同一个 URI。
- 表示的改变(HTTP 的动作) ── 使用动作, headers 和 body。
- 可自我描述的错误信息(HTTP 中的 status code) ── 使用状态码,不要重新造轮子。
- HATEOAS(HTTP 中的HTML 接口) ── 你的 web 服务器应该能够通过浏览器访问。
REST 请求的例子:
REST 关注于暴露数据。它减少了客户端/服务端的耦合程度,经常用于公共 HTTP API 接口设计。REST 使用更通常与规范化的方法来通过 URI 暴露资源,通过 header 来表述并通过 GET、POST、PUT、DELETE 和 PATCH 这些动作来进行操作。因为无状态的特性,REST 易于横向扩展和隔离。
缺点:REST¶
- 由于 REST 将重点放在暴露数据,所以当资源不是自然组织的或者结构复杂的时候它可能无法很好的适应。举个例子,返回过去一小时中与特定事件集匹配的更新记录这种操作就很难表示为路径。使用 REST,可能会使用 URI 路径,查询参数和可能的请求体来实现。
- REST 一般依赖几个动作(GET、POST、PUT、DELETE 和 PATCH),但有时候仅仅这些没法满足你的需要。举个例子,将过期的文档移动到归档文件夹里去,这样的操作可能没法简单的用上面这几个 verbs 表达。
- 为了渲染单个页面,获取被嵌套在层级结构中的复杂资源需要客户端,服务器之间多次往返通信。例如,获取博客内容及其关联评论。对于使用不确定网络环境的移动应用来说,这些多次往返通信是非常麻烦的。
- 随着时间的推移,更多的字段可能会被添加到 API 响应中,较旧的客户端将会接收到所有新的数据字段,即使是那些它们不需要的字段,结果它会增加负载大小并引起更大的延迟。
RPC 与 REST 比较¶
| 操作 | RPC | REST |
|---|---|---|
| 注册 | POST /signup | POST /persons |
| 注销 | POST /resign { "personid": "1234" } |
DELETE /persons/1234 |
| 读取用户信息 | GET /readPerson?personid=1234 | GET /persons/1234 |
| 读取用户物品列表 | GET /readUsersItemsList?personid=1234 | GET /persons/1234/items |
| 向用户物品列表添加一项 | POST /addItemToUsersItemsList { "personid": "1234"; "itemid": "456" } |
POST /persons/1234/items { "itemid": "456" } |
| 更新一个物品 | POST /modifyItem { "itemid": "456"; "key": "value" } |
PUT /items/456 { "key": "value" } |
| 删除一个物品 | POST /removeItem { "itemid": "456" } |
DELETE /items/456 |
资料来源:你真的知道你为什么更喜欢 REST 而不是 RPC 吗
来源及延伸阅读:REST 与 RPC¶
- 你真的知道你为什么更喜欢 REST 而不是 RPC 吗
- 什么时候 RPC 比 REST 更合适?
- REST vs JSON-RPC
- 揭开 RPC 和 REST 的神秘面纱
- 使用 REST 的缺点是什么
- 破解系统设计面试
- Thrift
- 为什么在内部使用 REST 而不是 RPC
安全¶
这一部分需要更多内容。一起来吧!
安全是一个宽泛的话题。除非你有相当的经验、安全方面背景或者正在申请的职位要求安全知识,你不需要了解安全基础知识以外的内容:
来源及延伸阅读¶
附录¶
一些时候你会被要求做出保守估计。比如,你可能需要估计从磁盘中生成 100 张图片的缩略图需要的时间或者一个数据结构需要多少的内存。2 的次方表**和**每个开发者都需要知道的一些时间数据(译注:OSChina 上有这篇文章的译文)都是一些很方便的参考资料。
2 的次方表¶
Power Exact Value Approx Value Bytes
---------------------------------------------------------------
7 128
8 256
10 1024 1 thousand 1 KB
16 65,536 64 KB
20 1,048,576 1 million 1 MB
30 1,073,741,824 1 billion 1 GB
32 4,294,967,296 4 GB
40 1,099,511,627,776 1 trillion 1 TB
来源及延伸阅读¶
每个程序员都应该知道的延迟数¶
Latency Comparison Numbers
--------------------------
L1 cache reference 0.5 ns
Branch mispredict 5 ns
L2 cache reference 7 ns 14x L1 cache
Mutex lock/unlock 25 ns
Main memory reference 100 ns 20x L2 cache, 200x L1 cache
Compress 1K bytes with Zippy 10,000 ns 10 us
Send 1 KB bytes over 1 Gbps network 10,000 ns 10 us
Read 4 KB randomly from SSD* 150,000 ns 150 us ~1GB/sec SSD
Read 1 MB sequentially from memory 250,000 ns 250 us
Round trip within same datacenter 500,000 ns 500 us
Read 1 MB sequentially from SSD* 1,000,000 ns 1,000 us 1 ms ~1GB/sec SSD, 4X memory
Disk seek 10,000,000 ns 10,000 us 10 ms 20x datacenter roundtrip
Read 1 MB sequentially from 1 Gbps 10,000,000 ns 10,000 us 10 ms 40x memory, 10X SSD
Read 1 MB sequentially from disk 30,000,000 ns 30,000 us 30 ms 120x memory, 30X SSD
Send packet CA->Netherlands->CA 150,000,000 ns 150,000 us 150 ms
Notes
-----
1 ns = 10^-9 seconds
1 us = 10^-6 seconds = 1,000 ns
1 ms = 10^-3 seconds = 1,000 us = 1,000,000 ns
基于上述数字的指标: * 从磁盘以 30 MB/s 的速度顺序读取 * 以 100 MB/s 从 1 Gbps 的以太网顺序读取 * 从 SSD 以 1 GB/s 的速度读取 * 以 4 GB/s 的速度从主存读取 * 每秒能绕地球 6-7 圈 * 数据中心内每秒有 2,000 次往返
延迟数可视化¶
来源及延伸阅读¶
其它的系统设计面试题¶
常见的系统设计面试问题,给出了如何解决的方案链接
| 问题 | 引用 |
|---|---|
| 设计类似于 Dropbox 的文件同步服务 | youtube.com |
| 设计类似于 Google 的搜索引擎 | queue.acm.org stackexchange.com ardendertat.com stanford.edu |
| 设计类似于 Google 的可扩展网络爬虫 | quora.com |
| 设计 Google 文档 | code.google.com neil.fraser.name |
| 设计类似 Redis 的键值存储 | slideshare.net |
| 设计类似 Memcached 的缓存系统 | slideshare.net |
| 设计类似亚马逊的推荐系统 | hulu.com ijcai13.org |
| 设计类似 Bitly 的短链接系统 | n00tc0d3r.blogspot.com |
| 设计类似 WhatsApp 的聊天应用 | highscalability.com |
| 设计类似 Instagram 的图片分享系统 | highscalability.com highscalability.com |
| 设计 Facebook 的新闻推荐方法 | quora.com quora.com slideshare.net |
| 设计 Facebook 的时间线系统 | facebook.com highscalability.com |
| 设计 Facebook 的聊天系统 | erlang-factory.com facebook.com |
| 设计类似 Facebook 的图表搜索系统 | facebook.com facebook.com facebook.com |
| 设计类似 CloudFlare 的内容传递网络 | cmu.edu |
| 设计类似 Twitter 的热门话题系统 | michael-noll.com snikolov .wordpress.com |
| 设计一个随机 ID 生成系统 | blog.twitter.com github.com |
| 返回一定时间段内次数前 k 高的请求 | ucsb.edu wpi.edu |
| 设计一个数据源于多个数据中心的服务系统 | highscalability.com |
| 设计一个多人网络卡牌游戏 | indieflashblog.com buildnewgames.com |
| 设计一个垃圾回收系统 | stuffwithstuff.com washington.edu |
| 添加更多的系统设计问题 | 贡献 |
真实架构¶
关于现实中真实的系统是怎么设计的文章。
Source: Twitter timelines at scale
不要专注于以下文章的细节,专注于以下方面:
- 发现这些文章中的共同的原则、技术和模式。
- 学习每个组件解决哪些问题,什么情况下使用,什么情况下不适用
- 复习学过的文章
| 类型 | 系统 | 引用 |
|---|---|---|
| Data processing | MapReduce - Google的分布式数据处理 | research.google.com |
| Data processing | Spark - Databricks 的分布式数据处理 | slideshare.net |
| Data processing | Storm - Twitter 的分布式数据处理 | slideshare.net |
| Data store | Bigtable - Google 的列式数据库 | harvard.edu |
| Data store | HBase - Bigtable 的开源实现 | slideshare.net |
| Data store | Cassandra - Facebook 的列式数据库 | slideshare.net |
| Data store | DynamoDB - Amazon 的文档数据库 | harvard.edu |
| Data store | MongoDB - 文档数据库 | slideshare.net |
| Data store | Spanner - Google 的全球分布数据库 | research.google.com |
| Data store | Memcached - 分布式内存缓存系统 | slideshare.net |
| Data store | Redis - 能够持久化及具有值类型的分布式内存缓存系统 | slideshare.net |
| File system | Google File System (GFS) - 分布式文件系统 | research.google.com |
| File system | Hadoop File System (HDFS) - GFS 的开源实现 | apache.org |
| Misc | Chubby - Google 的分布式系统的低耦合锁服务 | research.google.com |
| Misc | Dapper - 分布式系统跟踪基础设施 | research.google.com |
| Misc | Kafka - LinkedIn 的发布订阅消息系统 | slideshare.net |
| Misc | Zookeeper - 集中的基础架构和协调服务 | slideshare.net |
| 添加更多 | 贡献 |
公司的系统架构¶
| Company | Reference(s) |
|---|---|
| Amazon | Amazon 的架构 |
| Cinchcast | 每天产生 1500 小时的音频 |
| DataSift | 每秒实时挖掘 120000 条 tweet |
| DropBox | 我们如何缩放 Dropbox |
| ESPN | 每秒操作 100000 次 |
| Google 的架构 | |
| 1400 万用户,达到兆级别的照片存储 是什么在驱动 Instagram |
|
| Justin.tv | Justin.Tv 的直播广播架构 |
| Facebook 的可扩展 memcached TAO: Facebook 社交图的分布式数据存储 Facebook 的图片存储 |
|
| Flickr | Flickr 的架构 |
| Mailbox | 在 6 周内从 0 到 100 万用户 |
| 从零到每月数十亿的浏览量 1800 万访问用户,10 倍增长,12 名员工 |
|
| Playfish | 月用户量 5000 万并在不断增长 |
| PlentyOfFish | PlentyOfFish 的架构 |
| Salesforce | 他们每天如何处理 13 亿笔交易 |
| Stack Overflow | Stack Overflow 的架构 |
| TripAdvisor | 40M 访问者,200M 页面浏览量,30TB 数据 |
| Tumblr | 每月 150 亿的浏览量 |
| Making Twitter 10000 percent faster 每天使用 MySQL 存储2.5亿条 tweet 150M 活跃用户,300K QPS,22 MB/S 的防火墙 可扩展时间表 Twitter 的大小数据 Twitter 的行为:规模超过 1 亿用户 |
|
| Uber | Uber 如何扩展自己的实时化市场 |
| Facebook 用 190 亿美元购买 WhatsApp 的架构 | |
| YouTube | YouTube 的可扩展性 YouTube 的架构 |
公司工程博客¶
你即将面试的公司的架构
你面对的问题可能就来自于同样领域
- Airbnb Engineering
- Atlassian Developers
- Autodesk Engineering
- AWS Blog
- Bitly Engineering Blog
- Box Blogs
- Cloudera Developer Blog
- Dropbox Tech Blog
- Engineering at Quora
- Ebay Tech Blog
- Evernote Tech Blog
- Etsy Code as Craft
- Facebook Engineering
- Flickr Code
- Foursquare Engineering Blog
- GitHub Engineering Blog
- Google Research Blog
- Groupon Engineering Blog
- Heroku Engineering Blog
- Hubspot Engineering Blog
- High Scalability
- Instagram Engineering
- Intel Software Blog
- Jane Street Tech Blog
- LinkedIn Engineering
- Microsoft Engineering
- Microsoft Python Engineering
- Netflix Tech Blog
- Paypal Developer Blog
- Pinterest Engineering Blog
- Quora Engineering
- Reddit Blog
- Salesforce Engineering Blog
- Slack Engineering Blog
- Spotify Labs
- Twilio Engineering Blog
- Twitter Engineering
- Uber Engineering Blog
- Yahoo Engineering Blog
- Yelp Engineering Blog
- Zynga Engineering Blog
来源及延伸阅读¶
正在完善中¶
有兴趣加入添加一些部分或者帮助完善某些部分吗?加入进来吧!
- 使用 MapReduce 进行分布式计算
- 一致性哈希
- 直接存储器访问(DMA)控制器
- 贡献
致谢¶
整个仓库都提供了证书和源
特别鸣谢:
- Hired in tech
- Cracking the coding interview
- High scalability
- checkcheckzz/system-design-interview
- shashank88/system_design
- mmcgrana/services-engineering
- System design cheat sheet
- A distributed systems reading list
- Cracking the system design interview
联系方式¶
欢迎联系我讨论本文的不足、问题或者意见。
可以在我的 GitHub 主页上找到我的联系方式
许可¶
Creative Commons Attribution 4.0 International License (CC BY 4.0)
http://creativecommons.org/licenses/by/4.0/
what you should know ↵
每个开发人员都应该了解 GPU 计算的知识¶
原文:What Every Developer Should Know About GPU Computing
大多数程序员对 CPU 和顺序编程都有深入的了解,因为他们是在为 CPU 编写代码的过程中长大的,但许多程序员不太熟悉 GPU 的内部工作原理以及它们如此特别的原因。在过去的十年中,GPU 由于在深度学习中的广泛使用而变得异常重要。如今,每个软件工程师都必须对其工作方式有基本的了解。我写这篇文章的目的是为您提供背景知识。
本文的大部分内容基于 Hwu 等人所著的《Programming Massively Parallel Processors》第四版。由于本书涵盖了 Nvidia GPU,因此我还将讨论 Nvidia GPU 并使用 Nvidia 特定术语。然而,GPU 编程的基本概念和方法也适用于其他供应商。
CPU 和 GPU 的比较¶
我们将首先对 CPU 和 GPU 进行比较,这将使我们更好地了解 GPU 领域。然而,这是一个单独的主题,我们不可能将所有内容都压缩在一个章节中。因此,我们将坚持几个关键点。
CPU 和 GPU 之间的主要区别在于它们的设计目标。CPU 被设计为执行顺序指令-。为了提高顺序执行性能,多年来 CPU 设计中引入了许多功能。重点是减少指令执行延迟,以便 CPU 能够尽快执行指令序列。这包括指令流水线、乱序执行、推测执行和多级缓存等功能 (仅列出一些) 。
另一方面,GPU 专为大规模并行性和高吞吐量而设计,但代价是中等到高指令延迟。这设计方向受到了它们在视频游戏、图形、数值计算和现在深度学习中的使用的影响。所有这些应用程序都需要以非常快的速度执行大量线性代数和数值计算,因此人们对提高这些设备的吞吐量投入了大量注意力。
让我们考虑一个具体的例子。由于指令延迟较低,CPU 可以比 GPU 更快地添加两个数字。他们将能够以比 GPU 更快的速度连续执行多项此类计算。然而,当进行数百万或数十亿次此类计算时,GPU 由于其巨大的并行性而比 CPU 更快地完成这些计算。
如果你喜欢数字,我们就来谈谈数字吧。数值计算硬件的性能是根据每秒可以执行多少次浮点运算(FLOPS) 来衡量的。Nvidia Ampere A100 在 32 位精度下提供 19.5TFLOPS 的吞吐量。相比之下,英特尔 24 核处理器的 32 位精度吞吐量为 0.66TFLOPS (这些数字来自 2021 年)。而且,GPU 和 CPU 之间的吞吐量性能差距逐年扩大。
下图比较了CPU和GPU的架构:
正如您所看到的,CPU 将大量芯片面积专门用于可减少指令延迟的功能,例如大缓存.更少的 ALU 和更多的控制单元。相比之下,GPU 使用大量 ALU 来最大化其计算能力和吞吐量。它们使用非常少量的芯片区域作为缓存和控制单元,从而减少 CPU 的延迟。
延迟容忍、高吞吐量和利特尔定律¶
您可能想知道,GPU 如何容忍高延迟并提供高性能。我们可以借助排队论中的利特尔定律来理解这一点。它指出系统中的平均请求数(队列深度 Qd)等于请求的平均到达率(吐量 T)乘以服务请求的平均时间 (延迟 L)
在GPU 的背景下,这基本上意味着可以容忍系统中给定级别的延迟,以通过维护正在执行或等待的指令队列来实现目标吞吐量。GPU 中的大量计算单元和高效的线程调度使GPU 能够在内核执行时间内维护此队列,并在内存延迟较长的情况下实现高吞吐量。
GPU架构¶
因此,我们知道 GPU 偏爱高吞吐量,但它们的架构是什么样的才能实现这一目标,让我们在本节中讨论。
GPU计算架构¶
GPU 由一系列流式多处理器(SM) 组成。每个 SM 又由多个流处理器或核心或线程组成。例如,Nvidia H100 GPU 有132个SM,每个 SM有64 个核心,总共有 8448 个核心。
每个 SM都有有限数量的片上存储器,通常称为共享存储器或暂存器,在所有内核之间共享。同样,SM 上的控制单元资源由所有核共享。此外,每个 SM 都配备了基于硬件的线程调度程序来执行线程。
除此之外,每个 SM 还具有多个功能单元或其他加速计算单元,例如张量核心或光线追踪单元,以满足 GPU 所满足的工作负载的特定计算需求。
接下来,我们来分解一下 GPU 内存,看看里面的情况。
GPU内存架构¶
GPU 有多层不同类型的存储器,每层都有其特定的用例。下图显示了 GPU 中一个 SM 的内存层次结构。
让我们来分解一下。
-
寄存器: 我们将从寄存器开始。GPU中的每个SM都有大量的寄存器。例如,Nvidia A100 和 H100 型号的每个 SM有65,536 个寄存器。这些寄存器在内核之间共享,并根据线程的要求动态分配给它们。在执行期间,分配给线程的寄存器是该线程私有的,即其他线程不能读/写这些寄存器。
-
常量缓存: 接下来,我们在芯片上有常量缓存。它们用于缓存 SM 上执行的代码所使用的常量数据。为了利用这些缓存,程序员必须在代码中显式地将对象声明为常量,以便 GPU 可以缓存并将它们保存在常量缓存中。
-
共享内存: 每个 SM 还具有共享内存或暂存器,它是少量快速且低延迟的片上可编程 SRAM 内存。它被设计为由运行在 SM 上的线程块共享。共享内存背后的想法是,如果多个线程需要处理同一块数据,则只有其中一个线程应该从全局内存加载它,而其他线程则共享它。谨慎使用共享内存可以减少全局内存的几余加载操作提高内核执行性能。共享内存的另一个用途是作为块内执行的线程之间的同步机制。
-
L1 Cache: 每个 SM 还具有一个 L1 缓存,可以缓存L2 缓存中经常访问的数据。
-
L2 Cache: 有一个L2 Cache,由所有SM 共享。它缓存全局内存中经常访问的数据以减少延迟。注意,L1和L2高速缓存对于SM来说都是透明的,即SM不知道它正在从L1或L2获取数据。对于SM来说,它是从全局内存中获取数据。这类似于 CPU 中L1/L2/L3 缓存的工作方式。
-
全局内存: GPU还有一个片外全局内存,它是一种高容量、高带宽的DRAM。例如,Nvidia H100 拥有 80 GB 高带宽内存(HBM),带宽为 3000 GB/秒。由于距离SM较远,全局内存的延迟相当高。然而,片上存储器的几个附加层和大量计算单元有助于隐藏这种延迟 (请参阅 CPU 与GPU 部分中的利特尔定律讨论)。
现在我们已经了解了 GPU 硬件的关键组件,让我们更深入地了解这些组件在执行代码时如何发挥作用。
理解GPU的执行模型¶
要了解 GPU 如何执行内核,我们首先需要了解什么是内核以及它的配置是什么。
CUDA 内核和线程块简介¶
CUDA 是 Nvidia 提供的编程接口,用于为其 GPU 编写程序。在 CUDA 中,您以类似于 C/C++ 函数的形式表达要在 GPU 上运行的计算,该函数称为内核。内核对数字向量进行并行操作,这些向量作为函数参数提供给它。一个简单的例子是执行向量加法的内核,即,一个内核将两个数字向量作为输入,将它们按元素相加并将结果写入第三个向量。
为了在 GPU 上执行内核,我们需要启动许多线程,这些线程统称为网格。但网格还有更多结构。网格由一个或多个线程块(有时简称为块)组成,每个块由一个或多个线程组成。
块和线程的数量取决于数据的大小和我们想要的并行度。例如,在我们的向量加法示例中,如果我们要添加维度为 256 的向量,那么我们可能会决定配置一个包含 256 个线程的线程块,以便每个线程都对向量的一个元素进行操作。对于更大的问题,我们可能在 GPU 上没有足够的可用线程,并且我们可能希望每个线程处理多个数据点。
就实现而言,编写内核需要两个部分。一种是在 CPU 上执行的主机代码。这是我们加载数据、在 GPU 上分配内存以及使用配置的线程网格启动内核的地方。第二部分是编写在 GPU 上执行的设备 (GPU) 代码。
对于我们的向量加法示例,下图显示了主机代码。
下面是设备代码,它定义了实际的内核函数。
GPU 上执行内核的步骤¶
1. 将数据从主机复制到设备¶
在调度内核执行之前,必须将其所需的所有数据从主机 (CPU)的内存复制到 GPU (设备)的全局内存。尽管如此,在最新的 GPU 硬件中,我们还可以使用统一虚拟内存直接从主机内存中读取数据 (请参阅论文第 2.2 节:EMOGI: GPU 中内存外图遍历的高效内存访问)。
2. SM上线程块的调度¶
当 GPU 的内存中拥有所有必要的数据后,它会将线程块分配给 SM。块内的所有线程同时由同一个 SM 处理。为了实现这一点,GPU 必须在 SM 上为这些线程预留资源然后才能开始执行它们。实际中,多个线程块可以分配给同一个SM同时执行。
由于 SM 的数量有限,并且大内核可能具有大量块,因此并非所有块都可以立即分配执行。GPU 维护一个等待分配和执行的块列表。当任何块完成执行时,GPU 会分配等待列表中的块之一来执行。
3. 单指令多线程(SIMT)和扭曲¶
我们知道一个block的所有线程都被分配到同一个SM。但在此之后还有另一个级别的线程划分。这些线进一步分为32个尺寸,称为经线(称为经线)-) ,并一起分配在一组称为处理块的核心上执行。
SM 通过获取并向所有线程发出相同的指令来一起执行 warp 中的所有线程。然后,这些线程同时执行该指令,但针对数据的不同部分。在我们的向量加法示例中,,warp 中的所有线程可能都在执行加法指令,但它们将在向量的不同索引上进行操作。
这种 warp 的执行模型也称为单指令多线程(SIMT),因为多个线程正在执行同一指令它类似于CPU 中的单指令多数据(SIMD)指。
从 Volta 开始,新一代GPU 提供了一种替代指令调度机制,称为独立线程调度。它允许线程之间完全并发,而不管扭曲如何。它可以用来更好地利用执行资源,或者作为线程之间的同步机制。我们不会在这里讨论独立线程调度,但您可以在CUDA 编程指南中阅读相关内容
4. Warp调度和延迟容忍¶
关于扭曲的工作原理,有一些有趣的细节值得讨论。
即使 SM 中的所有处理块 (核心组)都在处理扭曲,但在任何给定时刻,只有少数块正在主动执行指令。发生这种情况是因为 SM 中可用的执行单元数量有限。
但有些指令需要更长的时间才能完成,导致等待结果时出现扭曲。在这种情况下,SM会将等待的 warp 置于睡眠状态,并开始执行另一个不需要等待任何操作的 warp。这使得 GPU 能够最大限度地利用所有可用的计算并提供高吞吐量 (利特尔定律在这里再次发挥作用)。
零开销调度: 由于每个线程束中的每个线程都有自己的一组寄存器,因此 SM 从执行一个线程束切换到执行另一个线程束时没有开销。
这与 CPU 上进程之间的上下文切换方式形成对比。如果一个进程正在等待长时间运行的操作,CPU 会同时在该核心上调度另一个进程。然而,CPU 中的上下文切换是昂贵的,因为 CPU 需要将寄存器保存到主存中,并恢复其他进程的状态。
5. 将结果数据从设备复制到主机内存¶
最后,当内核的所有线程都执行完毕后,最后一步是将结果复制回主机内存。
尽管我们涵盖了有关典型内核执行的所有内容,但还有一件事需要单独的部分: 动态资源分区。
资源划分和占用概念¶
我们通过称为“占用的指标来衡量 GPU 资源的利用率,该指标表示分配给 SM 的 warp数量与其可支持的最大数量的比率。为了实现最大吞吐量,我们希望拥有 100% 的占用率。然而,在实践中,由于各种限制,这并不总是可行。
那么,为什么我们总是不能达到100%的入住率呢? SM具有一组固定的执行资源,包括寄存器、共享内存、线程块槽和线程槽。这些资源根据线程的要求和 GPU 的限制在线程之间动态分配。例如,在Nvidia H100上,每个SM可以处理32个块,64个扭曲 (即2048个线程),每个块1024个线程。如果我们启动块大小为 1024 个线程的网格,GPU会将 2048 个可用线程槽分成 2 个块。
动态分区与固定分区:动态分区可以更有效地利用 GPU 中的计算资源。如果我们将其与固定分区方案进行比较,其中每个线程块接收固定数量的执行资源,那么它可能并不总是最有效的。在某些情况下,可能会为线程分配比其需要更多的资源,从而导致资源浪费和吞吐量降低。
现在,我们通过一个例子来看看资源分配如何影响SM的占用。如果我们使用 32 个线程的块大小并且总共需要 2048 个线程,那么我们将有 64 个这样的块。然而,每个 SM一次只能处理 32 个区块。因此,即使 SM 可以运行 2048 个线程,但它一次只能运行1024 个线程,从而导致 50% 的占用率。
同样,每个SM有65536个寄存器。要同时执行 2048 个线程,每个线程最多可以有 32个寄存器(65536/2048 = 32)。如果内核每个线程需要 64 个寄存器,那么每个 SM 只能运行 1024 个线程,同样会导致 50% 的占用率。
次优占用的挑战在于,它可能无法提供必要的延迟容忍度或达到硬件峰值性能所需的计算吞吐量。
高效创建 GPU 内核是一项复杂的任务。我们必须明智地分配资源,以保持高占用率同时最大限度地减少延迟。例如,拥有许多寄存器可以使代码运行得更快,但可能会减少占用率,因此仔细的代码优化很重要。
总结¶
我知道理解这么多新术语和概念是令人畏惧的。让我们总结一下要点,以便快速回顾。
- GPU 由多个流式多处理器(SM) 组成,其中每个 SM 具有多个处理核心。
- 有一个片外全局存储器,它是 HBM 或 DRAM。距离芯片上的SM较远,延迟较高。
- 有一个片外 L2 缓存和一个片内 L1 缓存。这些 L1和L2 高速缓存的运行方式与CPU 中 L1/L2 高速缓存的运行方式类似。
- 每个 SM 上都有少量可配置的共享内存。这是核心之间共享的。通常,线程块内的线程将一段数据加载到共享内存中,然后重用它,而不是从全局内存中再次加载它。
- 每个 SM 都有大量寄存器,这些寄存器根据线程的要求在线程之间进行分区Nvidia H100 每个SM 有 65,536 个寄存器
- GPU 根据资源可用性分配一个或多个块在 SM 上执行。一个块的所有线程都在同-个SM上分配和执行。这是为了利用数据局部性和线程之间的同步。
- 分配给 SM 的线程进一步分为 32 个大小,称为扭曲。warp 内的所有线程同时执行相同的指令,但在数据的不同部分(SIMT)。 (尽管新一代 GPU 也支持独立线程调度。)
- GPU根据每个线程的需求和SM的限制在线程之间执行动态资源划分。程序员需要仔细优化代码,以确保执行期间SM占用率达到最高水平
致谢¶
我要感谢Nvidia 的高级研究科学家Vikram Sharma Mailthody审阅了本文的各个部分并提供了见解。他的反馈极大地提高了文章的质量。我很感激。Vikram 对提高 GPU编程的认识非常感兴趣,因此,如果您有兴趣了解有关该领域的更多信息,请通过Twitter或LinkedIn与他联系。
更多资源¶
如果您想更深入地了解 GPU,可以参考以下一些资源:
- 《编程大规模并行处理器: 第四版》是最新的参考资料,但早期版本也很好大规模并行处理器编程: Hwu 教授的在线课程
- Nvidia的 CUDA C++ 编程指南
- GPU 计算的工作原理(YouTube)
- GPU 编程: 何时、为何以及如何?
- 有关利特尔定律,请参阅论文“ BaM System Architecture 中 GPU-Initiated OnDemand High-Throughput Storage Access”的第2.2节
what-should-you-know¶
每个程序员都应该了解的硬件知识¶
在追求高效代码的路上,我们不可避免地会遇到代码的性能瓶颈。为了了解、解释一段代码为什么低效,并尝试改进低效的代码,我们总是要了解硬件的工作原理。于是,我们可能会尝试搜索有关某个架构的介绍、一些优化指南或者阅读一些计算机科学的教科书(如:计算机组成原理)。但以上的内容可能都太过繁琐、细节太多,在阅读的过程中,我们可能会迷失在纷繁的细节中,没法很好地将知识运用到实践中。
本文旨在通过多个可运行的 benchmark 介绍常见的优化细节以及与之相关的硬件知识,为读者建立一个简单、有效的硬件心智模型。
Cache¶
首先要介绍的就是缓存 cache 。我们先来看一个引自 CSAPP 的经典例子:
pub fn row_major_traversal(arr: &mut Vec<Vec<usize>>) {
let n = arr.len();
for i in 0..n {
assert!(arr[i].len() == n);
for j in 0..n {
arr[i][j] += j;
}
}
}
pub fn column_major_traversal(arr: &mut Vec<Vec<usize>>) {
let n = arr.len();
for i in 0..n {
assert!(arr[i].len() == n);
for j in 0..n {
arr[j][i] += j;
}
}
}
在上面两个例子中,分别按行、按列迭代同样大小的二维数组。
我们对这两个函数进行 benchmark:
在上图中,纵轴是平均耗时,横轴是数组大小(如:2000.0 表示数组大小为:2000 x 2000)。我们看到按行迭代数组比按列迭代的效率高约 10 倍。
在现代的存储架构中,cpu 和主存之间是 cache 。cpu 中的寄存器、高速缓存、内存三者的数据读写速度越来越慢。
而当 cpu 读取一个数据的时候,会先尝试从 cache 中读取。如果发生 cache miss 的时候,才会将数据从主存中加载到 cache 中再读取。而值得注意的是,cpu 每一次的读取都是以 cache line 为单位的。也就是说,cpu 在读取一个数据的时候,也会将该数据相邻的、一个 cache line 内的数据也加载到 cache 中。而二维数组在内存中是按行排布的,换句话说,数组中相邻的两行是首尾相连排列的。所以在读取 arr[i] 的时候,arr[i + 1] 、arr[i + 2] 等相邻的数组元素也会被加载到 cache 中,而当下一次迭代中,需要读取数组元素 arr[i + 1] 时,就能直接从 cache 中取出,速度非常快。而因为以列读取数组时,arr[i][j] 和 arr[i + 1][j] 在内存中的位置就不再是紧密相连,而是相距一个数组行大小。这也导致了在读取 arr[i][j] 时,arr[i + 1][j] 并没有被加载到 cache 中。在下一次迭代时就会发生 cache miss 也就导致读取速度大幅下降。
prefetcher¶
如果我们不再是按某种顺序,而是随机地遍历数组,结果又会如何呢?
pub fn row_major_traversal(arr: &mut Vec<Vec<usize>>) {
let n = arr.len();
for i in 0..n {
assert!(arr[i].len() == n);
let ri: usize = rand::random();
std::intrinsics::black_box(ri);
for j in 0..n {
arr[i][j] += j;
}
}
}
pub fn column_major_traversal(arr: &mut Vec<Vec<usize>>) {
let n = arr.len();
for i in 0..n {
assert!(arr[i].len() == n);
let ri: usize = rand::random();
std::intrinsics::black_box(ri);
for j in 0..n {
arr[j][i] += j;
}
}
}
pub fn random_access(arr: &mut Vec<Vec<usize>>) {
let n = arr.len();
for i in 0..n {
assert!(arr[i].len() == n);
for j in 0..n {
let ri: usize = rand::random();
arr[j][ri % n] += j;
}
}
}
理论上来说,随机遍历和按列遍历都会导致频繁地 cache miss ,所以两者的效率应该是相近的。接下来,我们进行 benchmark:
可以看到,random_access 比 column_major 的效率还要低了 2 倍。原因是,在 cache 和 cpu 间还有 prefetcher:
我们可以参考维基百科上的资料:
Cache prefetching can be accomplished either by hardware or by software.
- Hardware based prefetching is typically accomplished by having a dedicated hardware mechanism in the processor that watches the stream of instructions or data being requested by the executing program, recognizes the next few elements that the program might need based on this stream and prefetches into the processor's cache.
- Software based prefetching is typically accomplished by having the compiler analyze the code and insert additional "prefetch" instructions in the program during compilation itself.
而 random_access 会让 prefetching 的机制失效,使得运行效率进一步下降。
cache associativity¶
如果我们按照不同的步长迭代一个数组会怎么样呢?
pub fn iter_with_step(arr: &mut Vec<usize>, step: usize) {
let n = arr.len();
let mut i = 0;
for _ in 0..1000000 {
unsafe { arr.get_unchecked_mut(i).add_assign(1); }
i = (i + step) % n;
}
}
steps 为:
let steps = [
1,
2,
4,
7, 8, 9,
15, 16, 17,
31, 32, 33,
61, 64, 67,
125, 128, 131,
253, 256, 259,
509, 512, 515,
1019, 1024, 1031
];
我们进行测试:
可以看见当 step 为 2 的幂次时,都会有一个运行时间的突起,一个性能的毛刺。这是为什么呢?要回答这个问题,我们需要温习一些计组知识。
cache 的大小是要远小于主存的。这就意味着我们需要通过某种方式将主存的不同位置映射到缓存中。一般来说,共有 3 种不同的映射方式。
全相联映射¶
全相联映射允许主存中的行可以映射到缓存中的任意一行。这种映射方式灵活性很高,但会使得缓存的查找速度下降。
直接映射¶
直接映射则规定主存中的某一行只能映射到缓存中的特定行。这种映射方式查找速度高,但灵活性很低,会经常导致缓存冲突,从而导致频繁 cache miss。
组相联映射¶
组相联映射则尝试吸收前两者的优点,将缓存中的缓存行分组,主存中某一行只能映射到特定的一组,在组内则采取全相联的映射方式。如果一组之内有 n 个缓存行,我们就称这种映射方式为 n 路组相联(n-way set associative)。
回到真实的 cpu 中,如:AMD Ryzen 7 4700u:
我们可以看到,L1 cache 大小为 4 x 32 KB (128KB) ,采取 8 路组相联,缓存行大小为 64 bytes 。也就是说,该缓存共有 4x32x1024 byte/64 byte = 2048 行,共分为 2048/8 = 256 组。也就是说,当迭代数组的步长为 时,数据更可能会被分到同一个组内,导致 cache miss 更加频繁,从而导致效率下降。
(注:我的 cpu 是 intel i7-10750H ,算得的 L1 cache 的组数为 384 ,并不能很好地用理论解释。)
false share¶
我们再来观察一组 benchmark:
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;
fn increase(v: &AtomicUsize) {
for _ in 0..10000 {
v.fetch_add(1, Ordering::Relaxed);
}
}
pub fn share() {
let v = AtomicUsize::new(0);
thread::scope(|s| {
let ta = s.spawn(|| increase(&v));
let tb = s.spawn(|| increase(&v));
let tc = s.spawn(|| increase(&v));
let td = s.spawn(|| increase(&v));
ta.join().unwrap();
tb.join().unwrap();
tc.join().unwrap();
td.join().unwrap();
});
}
pub fn false_share() {
let a = AtomicUsize::new(0);
let b = AtomicUsize::new(0);
let c = AtomicUsize::new(0);
let d = AtomicUsize::new(0);
thread::scope(|s| {
let ta = s.spawn(|| increase(&a));
let tb = s.spawn(|| increase(&b));
let tc = s.spawn(|| increase(&c));
let td = s.spawn(|| increase(&d));
ta.join().unwrap();
tb.join().unwrap();
tc.join().unwrap();
td.join().unwrap();
});
}
在 share 函数中,四个线程同时竞争原子变量 v 。而在 false_share 函数中,四个线程分别操作不同的原子变量,理论上线程之间不会产生数据竞争,所以 false_share 的执行效率应该比 share 要高。但 benchmark 的结果却出乎意料:
可以看到 false_share 比 share 的执行效率还要低。
在前文中提到,cpu 在读取数据时,是以一个 cache line 大小为单位将数据从主存中加载到 cache 中的。在前文中提到,笔者机器的 cache line 大小为:64 bytes 。而 false_share 函数中,四个原子变量在栈中的排布可能是:
a, b, c, d 四个原子变量在同一个 cache line 中,也就是说实际上四个线程实际上还是发生了竞争,产生了 false share 的现象。
那要如何解决这个问题呢?我们可以采用 #[repr(align(64))] (在不同的编程语言中又不同的写法),告知编译器将原子变量的内存地址以一个 cache line 大小对齐,从而避免四个原子变量位于同一个 cache line 中。
fn increase_2(v: &AlignAtomicUsize) {
for _ in 0..10000 {
v.v.fetch_add(1, Ordering::Relaxed);
}
}
#[repr(align(64))]
struct AlignAtomicUsize {
v: AtomicUsize,
}
impl AlignAtomicUsize {
pub fn new(val: usize) -> Self {
Self { v: AtomicUsize::new(val) }
}
}
pub fn non_share() {
let a = AlignAtomicUsize::new(0);
let b = AlignAtomicUsize::new(0);
let c = AlignAtomicUsize::new(0);
let d = AlignAtomicUsize::new(0);
thread::scope(|s| {
let ta = s.spawn(|| increase_2(&a));
let tb = s.spawn(|| increase_2(&b));
let tc = s.spawn(|| increase_2(&c));
let td = s.spawn(|| increase_2(&d));
ta.join().unwrap();
tb.join().unwrap();
tc.join().unwrap();
td.join().unwrap();
});
}
我们再次进行 benchmark,这一次 benchmark 的结果就符合我们的预测了:
可以看见 non_share 相比前两者,提升了近乎两倍的效率。
pipeline¶
我们再看一个 benchmark:
pub trait Get {
fn get(&self) -> i32;
}
struct Foo { /* ... */ }
struct Bar { /* ... */ }
impl Get for Foo { /* ... */ }
impl Get for Bar { /* ... */ }
let mut arr: Vec<Box<dyn Get>> = vec![];
for _ in 0..10000 {
arr.push(Box::new(Foo::new()));
}
for _ in 0..10000 {
arr.push(Box::new(Bar::new()));
}
// 此时数组中元素的排列时有序的
arr.iter().filter(|v| v.get() > 0).count()
// 将数组打乱
arr.shuffle(&mut rand::thread_rng());
// 再次测试
arr.iter().filter(|v| v.get() > 0).count()
测试结果为:
可以看见,sorted 和 unsorted 之间效率差约 2.67 倍。这是因为频繁的分支预测失败导致的。
在CPU 中,每一条指令的执行都会分为多个步骤,而现代计算机架构中存在一个结构 pipeline 可以同时执行处于不同执行阶段的指令。
而 pipeline 要高效地工作,需要在执行指令 A 时就将接下来要执行的指令 B, C, D 等提前读入。在一般的代码中,pipeline 可以有效地工作,但遇到分支的时候,我们就遇到难题了:
如图,pipeline 应该读入 Code A 还是 Code B 呢?在执行分支语句前,谁也不知道,CPU 也是。如果我们还想要 pipeline 高效工作的话,我们就只剩下一条路:猜。只要猜得足够准,我们的效率就能够接近没有分支的情况。“猜”这一步也有一个专业名词——流水线冒险。而现代计算机在编译器配合以及一些算法的帮助下,某些分支(如下图所示)的预测成功率可以高达 99% 。
分支预测失败的代价是要付出代价的。首先,我们要清除 pipeline 中的指令,因为它们不是接下来要执行的指令。其次,我们要将接下来要执行的指令一一加载进 pipeline 。最后,指令经过多个步骤被执行。
在测试代码中,我们打乱数组后,就会导致分支预测频繁失败,最终导致了执行效率的下降。
数据依赖¶
我们再来看一段代码:
pub fn dependent(a: &mut Vec<i32>, b: &mut Vec<i32>, c: &Vec<i32>) {
assert!(a.len() == 100000);
assert!(b.len() == 100000);
assert!(c.len() == 100000);
for i in 0..=99998 {
a[i] += b[i];
b[i + 1] += c[i];
}
a[9999] += b[9999];
}
pub fn independent(a: &mut Vec<i32>, b: &mut Vec<i32>, c: &Vec<i32>) {
assert!(a.len() == 100000);
assert!(b.len() == 100000);
assert!(c.len() == 100000);
a[0] += b[0];
for i in 0..=99998 {
b[i + 1] += c[i];
a[i + 1] += b[i + 1];
}
}
在这段代码中,我们通过两种不同的方式迭代数组,并最终达成一致的效果。我们画出,数据流图如下图:
在上图中,我们用箭头表示依赖关系(a[0] -> b[0] 表示 a[0] 的结果依赖于 b[0] ),用黑色箭头表示在循环外进行的操作,用不同的颜色,表示不同迭代中的操作。我们可以看到,在 dependent 中,不同颜色的箭头会出现在同一个数据流中,如:(a[1]->b[1]->c[0] 中就出现了红色和蓝色箭头),这就意味着第 n + 1 次迭代会依赖于第 n 次迭代的结果,而 independent 中则没有这种情况。
这会产生什么影响呢?我们来进行测试:
可以看到,出现了近 3 倍的效率差距。这有两方面原因。
一是数据依赖会导致 pipeline 效率以及 cpu 指令级并行的效率变低。
二是这种迭代之间的依赖会阻止编译器的向量化优化。我们观察等价的 cpp 代码(rust 1.71 的优化能力并不足以将 independet 向量化,我略感悲伤)。
#include <vector>
using i32 = int;
template<typename T>
using Vec = std::vector<T>;
void dependent(Vec<i32> &a, Vec<i32> &b, Vec<i32> &c) {
for (int i = 0; i < 9999; i++) {
a[i] += b[i];
b[i + 1] += c[i];
}
a[9999] += b[9999];
}
void independent(Vec<i32> &a, Vec<i32> &b, Vec<i32> &c) {
a[0] += b[0];
for (int i = 0; i < 9999; i++) {
b[i + 1] += c[i];
a[i + 1] += b[i + 1];
}
}
查看汇编:
dependent(...):
mov rax, rdx
mov rdx, QWORD PTR [rsi]
mov rcx, QWORD PTR [rdi]
mov rdi, QWORD PTR [rax]
xor eax, eax
.L2:
mov esi, DWORD PTR [rdx+rax]
add DWORD PTR [rcx+rax], esi
mov esi, DWORD PTR [rdi+rax]
add DWORD PTR [rdx+4+rax], esi
add rax, 4
cmp rax, 39996
jne .L2
mov eax, DWORD PTR [rdx+39996]
add DWORD PTR [rcx+39996], eax
ret
independent(...):
mov rax, QWORD PTR [rdi]
mov rcx, rdx
mov rdx, QWORD PTR [rsi]
lea rdi, [rax+4]
mov esi, DWORD PTR [rdx]
add DWORD PTR [rax], esi
lea r8, [rdx+4]
mov rsi, QWORD PTR [rcx]
lea rcx, [rdx+20]
cmp rdi, rcx
lea rdi, [rax+20]
setnb cl
cmp r8, rdi
setnb dil
or ecx, edi
mov rdi, rdx
sub rdi, rsi
cmp rdi, 8
seta dil
test cl, dil
je .L9
mov rcx, rax
sub rcx, rsi
cmp rcx, 8
jbe .L9
mov ecx, 4
.L7:
movdqu xmm0, XMMWORD PTR [rsi-4+rcx]
movdqu xmm2, XMMWORD PTR [rdx+rcx]
paddd xmm0, xmm2
movups XMMWORD PTR [rdx+rcx], xmm0
movdqu xmm3, XMMWORD PTR [rax+rcx]
paddd xmm0, xmm3
movups XMMWORD PTR [rax+rcx], xmm0
add rcx, 16
cmp rcx, 39988
jne .L7
movq xmm0, QWORD PTR [rsi+39984]
movq xmm1, QWORD PTR [rdx+39988]
paddd xmm0, xmm1
movq QWORD PTR [rdx+39988], xmm0
movq xmm1, QWORD PTR [rax+39988]
paddd xmm1, xmm0
movq QWORD PTR [rax+39988], xmm1
mov ecx, DWORD PTR [rdx+39996]
add ecx, DWORD PTR [rsi+39992]
mov DWORD PTR [rdx+39996], ecx
add DWORD PTR [rax+39996], ecx
ret
.L9:
mov ecx, 4
.L6:
mov edi, DWORD PTR [rdx+rcx]
add edi, DWORD PTR [rsi-4+rcx]
mov DWORD PTR [rdx+rcx], edi
add DWORD PTR [rax+rcx], edi
add rcx, 4
cmp rcx, 40000
jne .L6
ret
每个程序员都应该了解的内存知识 ↵
每个程序员都应该了解的内存知识¶
来源:每个程序员都应该了解的内存知识(What every programmer should know about memory)
原文作者:Ulrich Drepper
原文:
- RAM
- CPU caches
- Virtual memory
- NUMA systems
- What programmers can do - cache optimization
- What programmers can do - multi-threaded optimizations
- Memory performance tools
- Future technologies
- Appendices and bibliography
原文写于 2007 年,全文 PDF 有 114 页。我写这篇文章不是对原文的翻译,而是以原文的结构,对每部分,结合其他材料,进行解释与拓展;此外,原文有一些关于电路实现的内容,我认为对程序员而言理解到 ISA 级别即可,因此跳过了电路的内容。
内容若有错误、纰漏,还望您不吝赐教。
RAM 与 总线¶
在进行这部分之前,我想先说明一些名词(x86 平台)
- socket(插槽):固定 CPU 的插座,如下图。一般个人电脑主板上只有一个 socket,服务器主板可能有多个 socket。
- processor(处理器):封装了 core 以及其他电路的物理实体芯片。通常把两个或更多独立 core 封装在一个单一集成电路(IC)中的方案会称为多核心处理器。如图就是 1 个处理器。
- core(核心):“物理核心”,多核处理器上的独立处理单元实例。
- thread(线程):“逻辑核心”,在硬件角度上来说,实际上是添加了计算单元和逻辑单元,但是没有分配缓存和控制器的逻辑核心。这个逻辑核心可以进行独立的计算,但是缓存【无论是指令缓存还是数据缓存】是和“物理核心 core”共享的,也就是说“物理核心”分出了一部分 L1 和 L2 给超线程的逻辑核心。任务管理器里的框框就是 thread。一般来说 1 个 core 有 2 个 thread。1
The following resources are shared between two threads running in the same core:
- Cache
- Branch prediction resources
- Instruction fetch and decoding
-
Execution units
-
CPU :一个模糊的概念,可以指 processor, core, thread. 本文 p1 中的 CPU 指 processor.
该部分详细描述了随机存取存储器 (RAM) 的技术细节,包括 SRAM,DRAM 的电路实现,访问时序等细节。对于这些细节,程序员简单了解即可,更值得关注的是 CPU 访问内存,外部设备的模型,即 CPU-总线模型。
如图,是一个典型的南桥,北桥结构。
所有 CPU(在示例中是两个,但可以有更多)都通过公共总线(前端总线,FSB)连接到北桥。北桥包含内存控制器。
要访问所有其他系统设备,北桥必须与南桥通信。南桥,通常称为 I/O 桥,通过各种不同的总线处理与设备的通信。如今,PCI、PCI Express、SATA 和 USB 总线最为重要,但南桥也支持 PATA、IEEE 1394、串行和并行端口。
但如今的 x86 机器已经发生了一些变化,以上的模型已经不够准确了。以 Intel 为例,简要介绍 CPU 与总线系统的变化,仅介绍模型,忽略具体细节。图片来自 An Introduction to the Intel® QuickPath Interconnect2。
共享前端总线 (FSB),2004 年之前¶
和最初的模型一致,所有处理器共享 FSB 总线连接到南桥;而南桥与内存,外部设备(通过北桥)通信。
双独立总线,大约 2005 年¶
和上一个模型的变化就是,单共享总线变成了双独立总线(DIB),其余大致相同。
处理器独占总线,2007 年¶
Intel 的官方的叫法是 Dedicated High-speed Interconnects(DHSI,专用高速互联)。实际就是每个处理器独占一条 FSB 总线与南桥相连。
QPI 时代,2009 年¶
Intel® QuickPath Interconnect(QPI)技术最早发布于 2009 年 3 月的代号为 Nehalem 的 Xeon 处理器上。可以把 QPI 也理解为一种总线,总线本身怎么变化我们不关心,我们关注的是模型的变化。图二是 X58 平台的结构图,请结合以上两张图进行理解3。
最重要的变化是,内存控制器被集成进了 CPU 里,不再由北桥负责。北桥仅负责 PCI-E 以及和南桥通讯(图二)。而每个 CPU 都有一个内存控制器,都可以独立的访存,互相之间通过 QPI 进行通讯,架构变成了明显的非统一内存访问架构(NUMA4),这也带来了数据一致性等额外的问题。
后 QPI 时代,2012 年¶
从 E5 处理器开始(Q2'12),又发生了一些变化。
CPU 同时集成内存控制器和 PCI-E 控制器,传统意义的北桥消失,QPI 只负责 CPU 之间的通信;芯片组(传统意义的南桥)和 CPU 的通信使用 DMI 总线。
自 2017 年起,Intel Ultra Path Interconnect (UPI) 代替了 QPI 总线技术5。但 CPU-总线模型没有变化。
虽然 CPU-总线模型在不断变化,但有一个特点没有变:CPU 运行速度远高于访问内存,外部设备的速度。
基于这个不变,后文关于 Cache 等内容的叙述依旧成立。
虚拟内存¶
处理器的虚拟内存子系统实现提供给每个进程的虚拟地址空间,这使得每个进程都认为它独占系统的全部内存。
虚拟内存的优点列表在别处有详细描述,这里不再重复。相反,本节将重点放在虚拟内存子系统的实际实现细节和相关成本上。
虚拟地址空间由 CPU 的内存管理单元 (MMU) 实现。操作系统填写页表 (page table) 数据结构,但查找过程由 CPU 自己完成(x86 体系如此)。MMU 执行地址转换的输入是虚拟地址 (VA,virtual address), 输出是物理地址 (PA,physical address)。
在介绍页表之前,先来说一下现代大多体系结构的访存粒度:
- 字节 (byte) 编址;在表示地址时,0x100 与 0x104 相隔了 4 个字节,此外,CPU 能直接操作的最小单元也是字节,你可以 mov byte ptr [ebx], 10,若要操作某一 bit ,则无法直接进行,要通过移位等操作。
-
字 (word) 作为基本处理单位;
- 字长并非一个十分严格的概念。在一个 CPU 指令集中,每条指令都可以处理长度不同的操作数。这时就把大多数指令能处理的最长长度但是又不花费额外周期的操作数长度称为字长(32 位处理器为 4 字节,64 位处理器为 8 字节)。
- 当处理器从存储器子系统读取数据至寄存器,或者,写寄存器数据到存储器,传送的数据通常是字。
- 虽然从逻辑上,你可以单独操作某一个字节,但通常实际存取通常都是以字为单位的。这也带来的字节对齐的问题6。
-
在引入 cache 之后,处理器访问主存首先通过 cache ,而 cache 与主存存取的单位通常为 1 cache line (通常为 64 Bytes)
- MMU 进行虚拟内存地址映射时,单位为 page (通常为 4 KiB)。例如 0x10000 虚拟地址映射到 0xF0000, 指的是 0x10000-0x1FFFF 这连续的 1 page(假设为 4 KiB)地址空间映射到 0xF0000-0xFFFFF。以 page 为单位进行映射,也以 page 为单位进行管理,但 page 的大小是可以设置的。
最简单的一级页表¶
通常,VA 的低位部分作为 offset ,用于在 page 内进行偏移,假设 offset 字段长 bits,那 1 page 的大小就是
bytes。VA 的高位部分作为索引,用于在页表中确定页表项。页表项说明了 VA 与 PA 的映射关系,以及其他的一些访问权限管理。
注意,页表本身存放在内存中,由操作系统进行初始化,并更新维护,但 VA 到 PA 的翻译查找过程由 CPU 硬件完成。
多级页表¶
对于 4 KiB 页面,虚拟地址 VA 的偏移量 offset 部分只有 12 位大小。这留下 20 位作为页表的索引。但包含220个条目的页表是不切实际的。即使每个条目只有 4 个字节,表的大小也将是 4 MiB。由于每个进程都可能有自己不同的页表,系统的大部分物理内存都将被这些页面目录占用。
解决方案是使用多级页表。通过多级页表可以表示一个稀疏的大页面目录,其中实际未使用的区域不需要分配内存。因此,表示更加紧凑,可以在内存中拥有许多进程的页表,而不会对性能产生太大影响。
如图,VA 被分为六个部分。
- Sign Extend: 对于 4 级页表无用。
- 4 级页表下,x64 仅使用 48 位地址空间。5 级页表也正在路上7。
- Page-Map Level-4 Offset (PML4) : 4 级页表索引
- Page-Directory Pointer Offset:3 级页表索引
- Page-Directory Offset:2 级页表索引
- Page-Table Offset:1 级页表索引
- Physical-page Offset:用于在 page 页内的偏移,不参与转换过程
CPU 有一个专用的寄存器(x86-64 上为 CR3),保存着最高级(即 4 级)页表(PML4)的物理地址。
从 PML4 到 Page-Table Offset 的四个部分是各级页表的索引。第 4 级到第 2 级目录的内容是对下一级目录的引用。如果一个目录条目被标记为空,它显然不需要指向任何较低的目录。这样页表树就可以稀疏紧凑。每一级页表中,最多可以有 29 = 512 个页表项。
为了确定对应于虚拟地址的物理地址,处理器首先确定最高级别页表的物理地址。该地址通常存储在寄存器中。然后 CPU 获取与该目录对应的虚拟地址的索引部分,并使用该索引来选择适当的条目。此条目是下一个目录的物理地址,它使用虚拟地址的下一部分进行索引。这个过程一直持续到它到达一级目录,此时页表目录项的值是物理地址的高位部分,物理地址的低位部分是虚拟地址的低位部分,二者组合得到了此 VA 对应的 PA 。这个过程称为页表树遍历。大部分架构的处理器(如 x86 和 amd64,arm,risc-v)在硬件中执行此操作。
一个小程序可能只使用第 2、3 和 4 级每个目录中的一个目录和所有 1 级目录。在具有 4KiB page 和每个页表目录 512 个条目的 x86-64 上,这允许寻址 2MiB 地址。
下图是 x64 平台,每一级页表项的详细信息。
Huge page¶
通常,page 大小为 4 KiB,我们也可以把 page 设置的更大,以减少地址翻译的过程,这就是 huge page(或 large page)。
Windows 和 Linux 都允许服务器应用程序建立大页面内存区域。使用较大的2 MB 页面,只需10个页面就可以映射20 MB 的内存; 而使用4KB 页面则需要5120个页面。这意味着需要更少的 TLB 条目,从而减少 TLB miss 的发生。
在 64 位的 x86-64 中,支持 2MiB(4KB * 2^9= 2MB,PD(2 级页表)跳过 PT(1 级页表) 直接指向 page)和 1GiB(2MB * 2^9 = 1GB,PDPT(3 级页表) 跳过 PD 和 PT(2 级和 1 级页表) 直接指向 page)的 large page8。
下图9,分别是 1 GiB,2 MiB,4 KiB 页表项的 Entry 说明。
关于 huge page 的更多资料,可以参考 Java, MySql increased performance with Huge Pages
TLB¶
为了完成 VA 到 PA 的转换,我们需要用到页表。但页表本身放在内存中,对于多级页表而言,完成这一转换需要进行多次访存,其开销很大。
下表说明了在 i7 与 Xeon 处理器上10,Cache 访问以及内存访问所需的大概时间,可以看到访存所需的时间大概是 L1 Cache 的 30~50 倍,L3 Cache 的 3~5 倍。
Core i7 / Xeon 5500 Series Data Source Latency (approximate) [Pg. 22]
local L1 CACHE hit, ~4 cycles ( 2.1 - 1.2 ns )
local L2 CACHE hit, ~10 cycles ( 5.3 - 3.0 ns )
local L3 CACHE hit, line unshared ~40 cycles ( 21.4 - 12.0 ns )
local L3 CACHE hit, shared line in another core ~65 cycles ( 34.8 - 19.5 ns )
local L3 CACHE hit, modified in another core ~75 cycles ( 40.2 - 22.5 ns )
remote L3 CACHE (Ref: Fig.1 [Pg. 5]) ~100-300 cycles ( 160.7 - 30.0 ns )
local DRAM ~60 ns
remote DRAM ~100 ns
为了加快这一过程,我们可以将 VA 到 PA 的映射关系缓存到高速器件中,而不需要再一层层访存查询,TLB(Translation lookaside buffer)便应运而生。
TLB 也是一块的高速缓存,与常规意义的 Cache 物理实现类似,不过 Cache 缓存的是内存中的任意内容,而 TLB 仅缓存页表项的内容。
TLB 的工作过程¶
虚拟地址转换过程是以 page 为单位的,因此 VA 的低位部分不变成为 PA 的低位部分,VA 的高位部分需要转换成 PA 的高位部分,组合得到 PA。由此产生了4个概念:VPN(virtual page number),PPN(physical page number),VPO(virtual page offset)和 PPO(physical page offset)。
为了在 TLB 中搜索,VPN 部分又被分为两部分 TLB tag 和 TLB Index.
使用 TLB Index 部分(不需要存在 TLB 中,就像数组的下标)在 TLB 中索引,使用 TLB Tag 部分比对,以确定是否为对应的 VA 。TLB 中真正缓存的是页表项的内容(Page Table Entry),包括 VA 对应的 PA ,权限控制位,valid 标志等。图源11,该图只是简单的说明,并不代表现 X86 TLB entry 内容。
TLB flush¶
这篇文章 TLB之flush操作 讲的十分清晰,明了。
关于 TLB flush 涉及优化的点:
-
内核页表相对不变,我们希望它不要总被 flush 掉
- 现代处理器(ARM 和 x86)采用一个名为 G(Global)的 bit,来代表那些在系统运行中不常变化的映射关系。以 x86为例,当 CR3寄存器中的内容被更新(因为 CR3是指向进程页表的首地址的,这意味是发生了进程切换),默认会 flush 整个 TLB。
- 如果在 CR4寄存器里置位了 PGE(page global enable),则 TLB 里的 G 标志位就生效了,含有 G 的 TLB entries 就不会被 flush 了,成了钉子户了。当然,reset 的时候,钉子户也是会被清掉的。
- 利用此机制也可以在进程间方便的共享内存
-
进程切换频繁,从 A 进程切换成 B 进程时,TLB flush 掉 A 的页表,当又切换回 A 进程时,A 面临一个空的 TLB,这个过程频繁发生,会降低性能
- 置位 CR4寄存器的 PCIDE(PCID Enable)位后,PCID 就生效了,进程切换的时候就不需要 flush TLB 了。但是 CR3中的 PCID 只占12个 bits,也就是说,它最多能表达4096个 process。
TLB 层级¶
普通的 Cache 分为 L1,L2 等多级缓存,TLB 也存在这个分层结构。
- TLB
- L1 TLB (per core)
- L1 Instruction TLB
- L1 Data TLB
- L1 TLB (per core)
- L2 TLB (Unified)
Intel 的 TLB 层级及大小具体如下12。
PTI(Page Table Isolation)¶
如果没有 PTI,每当执行用户空间代码(应用程序)时,Linux 会在其分页表中保留整个内核内存的映射,并保护其访问。
但这带来了 Meltdown & Spectre 漏洞。
我们把CPU比做学校食堂,把黑客比作两个男生A,B,用户则是女神。
这天,男生A,B总要想办法获得女神的一点私密信息——比如,女神今天午饭吃的啥~
中午,女神来到食堂打饭,点了一份小笼包。
男生A在女神后面跟食堂大娘说:我也来一份,跟她一样的~
然而这会食堂大娘表示,你等会,你前面还有人哦。
好吧,虽然说是这么说,但是后面的厨房师傅已经听到了对话,已经提前开始准备好了另一份小笼包.....
后来女神点好走了,轮到男生A点,他表示,我要一个跟她一样的...
然而这会,
食堂大娘表示,人家是人家,你是你,我们不能透露女神隐私喔,你可以走了,下一个! (这就是目前CPU的内置的安全防线)
然而!
当下一个男生B走到食堂大娘面前,直接说:随便,哪道菜最快给我上哪道...
于是乎,既然之前厨房师傅已经提前多准备好了一份小笼包,就干脆直接把小笼包给了男生B....
这下男生知道了,女神中午吃了小笼包......
https://blog.csdn.net/pwl999/article/details/112686914
PTI通过完全分离用户空间与内核空间页表来解决页表泄露。但 PTI 也带来了更多的性能开销。
CPU caches¶
在 TLB 那一节我们介绍了,CPU 访问 L1 Cache 的花费大概是 2ns ,L2 Cache 大概是 4ns ,DRAM 大概是 60 ns,访问 Cache 比访问内存快得多。
Cache 层级¶
- CPU Cache
- L1 Cache
- L1 i-cache
- L1 d-cache
- L2 Cache
- L3 Cache
- L1 Cache
如图为酷睿 i7 - 9xx 的缓存层级图,该 CPU 为 4c8t,每个 core 独占 L1 i-cache 缓存指令(如 .text 段),L1 d-cache 缓存数据(如 .data .bss 段等),i-cache 与 d-cache 共用一个 L2 Cache。L3 Cache 为所有 cores 共用。
其中, L1 Cache 分别为 32KiB,L 2 Cache 为 256KiB,L 3 Cache 为 8MB。
在 Linux 上,可以通过 dmidecode -t cache 或 lscpu 获取你本机的 Cache 信息。
Cache 读策略¶
参考:CPU Cache and Memory Ordering
通常来说,L1 ⊂L2 ⊂L3 ⊂main memory
如图,来自 Intel 的 White paper13。
每个物理核心都包含自己的私有 L1和 L2 缓存。然而,L3 是共享的,可以被系统中的所有内核完全访问和利用。在英特尔 Xeon v3处理器上,L3 是一个 Inclusive 的高速缓存 (一个包容性的高速缓存包括所有存储在低级别的高速缓存中的数据)。如果对数据的请求错过了一个内核的 L1和 L2,那么这个请求就会继续到 L3 去接受服务。如果 L3 hit,那么可能需要窥探 (snooping)以保持与另一个可能拥有该数据的核的一致性。否则,在 L3 miss 的情况下,请求将在内存中得到服务。
在几乎所有的情况下,当内核从内存中请求数据时,每个缓存中也会有一个副本。因此,当一个请求错过了 L1 时,它可能会在 L2 或 L3 中找到。然而,当 L3 的 cache line 被驱逐时,如果 L1和 L2 存在的话,该 cache line 也必须被废止。这种情况可能会发生,例如,当一个核在它的 L1中有数据而它已经有一段时间没有使用了。当其他内核使用 L3 时,LRU 可能最终会从 L3 中驱逐该 Cache line 。因此,该核的 L1和 L2 缓存中对应的 cache line 将变得无效。
Cache 写策略¶
通过 dmidecode -t cache 命令,同样可以知道本机的 Cache 写策略,我的如下,均为 Write Back 。
Handle 0x000C, DMI type 7, 27 bytes
Cache Information
Socket Designation: L1 - Cache
Configuration: Enabled, Not Socketed, Level 1
Operational Mode: Write Back
Location: Internal
Installed Size: 512 kB
Maximum Size: 512 kB
Supported SRAM Types:
Pipeline Burst
Installed SRAM Type: Pipeline Burst
Speed: 1 ns
Error Correction Type: Multi-bit ECC
System Type: Unified
Associativity: 8-way Set-associative
Handle 0x000D, DMI type 7, 27 bytes
Cache Information
Socket Designation: L2 - Cache
Configuration: Enabled, Not Socketed, Level 2
Operational Mode: Write Back
Location: Internal
Installed Size: 4 MB
Maximum Size: 4 MB
Supported SRAM Types:
Pipeline Burst
Installed SRAM Type: Pipeline Burst
Speed: 1 ns
Error Correction Type: Multi-bit ECC
System Type: Unified
Associativity: 8-way Set-associative
Handle 0x000E, DMI type 7, 27 bytes
Cache Information
Socket Designation: L3 - Cache
Configuration: Enabled, Not Socketed, Level 3
Operational Mode: Write Back
Location: Internal
Installed Size: 8 MB
Maximum Size: 8 MB
Supported SRAM Types:
Pipeline Burst
Installed SRAM Type: Pipeline Burst
Speed: 1 ns
Error Correction Type: Multi-bit ECC
System Type: Unified
Associativity: 16-way Set-associative
Cache 结构¶
直接映射,全相联映射,N-路组相联映射,这三个听起来令人困惑,下面这张图很好的进行了解释。图中右下方的表为 2 路组相联,共有 16 组。
Cache 是对内存进行的缓存,其单位为 Cache line,1 Cache line 通常为 64 KiB。
- 读 1 字节,但不在 Cache 中 -> 需要从内存中读 1 Cache line.
- 仅修改了 1 字节,写回内存 -> 需要把 1 Cache line 全部写回(通常如此)
如图展示了 Cache 的查找过程。
VIPT, PIPT¶
进一步阅读:Cache组织方式
根据 Cache 使用什么做 index,什么做 tag,一共有四种:PIPT, PIVT, VIVT, VIVT.
同时,要考虑避免两个问题:
- 歧义(ambiguity):不同的数据在 cache 中具有相同的 tag 和 index。cache 控制器判断是否命中 cache 的依据就是 tag 和 index,因此这种情况下,cache 控制器根本没办法区分不同的数据。
- 别名(alias):当不同的虚拟地址映射相同的物理地址,而这些虚拟地址的index不同,此时就发生了别名现象(多个虚拟地址被称为别名)
具体而言,有很多细节问题需要考虑,请看上面链接里的文章。
PIPT:Physical Index Physical Tag。物理地址做 index,物理地址做 tag。
在 Cache 中查找需要等到 VA 转化为 PA 之后。
VIPT:Virtual Index Physical Tag。虚拟地址做 index,物理地址做 tag。
在 Cache 中查找与 VA 转化为 PA 的过程同时进行,性能有所提升。
Intel 没有公布过他们的实现方法,下图是 ARM 系列的。
缓存一致性¶
缓存一致性是最终存储在多个本地缓存中的共享资源数据的一致性。当系统中的客户端维护公共内存资源的缓存时,可能会出现数据不一致的问题,在多处理系统中,CPU 尤其如此。
为实现缓存一致性,常见的协议有 MESI ,MOESI 等,在此不讨论。为了达到一致性,core 需要监听总线(Bus snooping),一切都是有代价的。
但提到 cache coherence, 一般人都会有以下两个疑问14:
- 两个 core 试图“同时”修改同一个 cache line . Who will win?Can both lose ? Intel 的实现如何处理这种情况?
- 一个 core 尝试从一个不在 cache 的内存位置读取,而另一个 core 拥有一个具有该内存位置的 cache line 的独占所有权,并尝试(同时)向其中写入一些值。谁会赢?Cache line 状态将首先转移到一个共享状态,然后无效或修改,然后共享?(提到的状态是 MESI 的内容)
这个东西很难, 但是看起来这两个问题对 Intel 来说很轻松就做到了? 这是一个专家对以上问题的解答(how intel cache coherence works)
对 #1 的简短回答是,一致性处理将序列化操作。例如,如果两个 core 在同一周期内执行一个存储指令,并且都在其 L1和 L2 Cache miss,那么该事务将转到负责该地址的 L3片,该片将按顺序处理传入的请求。其中一个将 "win",并将被授予对 cache line 的独家访问权,以执行存储。在这期间,来自 "lose "核心的请求将被搁置或拒绝,直到最终第一个核心完成其一致性事务。
The short answer to #2 is that also that the transactions will be serialized. Either the owning processor will complete the store, then the line will be transferred to the reading processor, or the cache line will be transferred away from the owning processor to the reading processor first, then returned to the (original) owning processor to complete the store.
---“Dr. Bandwidth”
Cache 一致性解决了核之间数据可见性以及顺序的问题, 本质上可以把 cache 当做内存系统的一部分, 有没有 cache 对各个 CPU 来说都是一样的, 只要写到了 cache(所以可以有很多级 cache), 其他核就可以看到该数据, 并且顺序是确定的,至于具体的实现细节 Intel 并没有公布过。
Load/Store Buffer¶
其实在 Core 与 L1 Cache 之间还有器件,即 Load Buffer 和 Sotre Buffer,见下图中圈起来的位置。
这两个 buffer 是下图中② out of order engine 中的一部分, 顾名思义就是跟乱序执行有关系.
数据读取(load buffer)跟不影响数据对外的可见性, 但是 store buffer 会, 数据从 CPU 的 store buffer 出来到 cache, 上其他核就可以看到该数据.
store buffer类似于一个队列, 但是大小是按照entry计算不是按照bits计算的. 上图的例子是56 entries, 一般来说 store buffer的size 是load buffer的 ⅔, 因为大 多数程序读别写多.
开了超线程(hyper-threading), store buffer 这些资源就要等分成两份, 分别给两个逻辑核(thread)使用.
CPU 操作数据变更之后并不会立即写到 L1d 上, 而是先写到 store buffer 上, 然后尽快写到 L1d cache。
举一个例子, store buffer 会引起”乱序”的问题:
// global int
int a = 0;
int b = 0;
// thread 1 | // thread 2
t1: | t2:
a = 1; | b = 1;
if (b == 0) { | if (a == 0) {
// do something | // do something
} else { | } else {
goto t1; // retry | goto t2; // retry
} | }
// http://gavinchou.github.io/summary/c++/memory-ordering/
上述代码执行完之后, 两个线程都能同时走到 “do something” 的逻辑里, 因为 thread 1 在写 a = 1之后, 数据在 store buffer 里, 对 thread 2其实是不可见的, 这个时候 thread 2看到的还是 a == 0, 同理, thread 2写完 b = 1 会在 store buffer 里停留一段时间, thread 1 也看到 b == 0, 这样两个线程都认为自己可以进入到”do something” 的逻辑里.
这个其实是著名的”Peterson’s and Dekker’s algorithm”(互斥锁), 上述例子也阐述了这个算法在现代CPU架构上这么做是不可行的.
Store buffer 是指令重排的一部分,具体请见文章:内存屏障今生之Store Buffer, Invalid Queue
完整访存过程¶
至此,我们可以梳理一下完整的访存过程了(PIPT)。
NUMA (non-uniform memory access)¶
见本文 P1 。
程序员可以做什么¶
绕过 Cache¶
当生成数据而没有(立即)再次使用时(违背时间局部性),然后修改缓存数据的事实对性能是有害的。这个操作将可能需要的数据从 cache 中逐出,以支持不会很快使用的数据。对于像矩阵这样的大型数据结构尤其如此,这些结构需要填充,以便在之后使用。在填充矩阵的最后一个元素之前,第一个元素就会因为矩阵太大被踢出 cache ,使 cache 效率降低。
对于这类情况,処理器提供了对 非暂存(non-temporal) 写入操作的支持。这个情境下的非暂存指的是数据在短期内不会被使用,所以没有任何缓存它的必要。这些非暂存的写入操作不会先读 cache line 然后再修改;反之,新的内容会被直接写进内存。
这听来代价高昂,但并非如此。処理器会尝试使用写合并(write combining )来填充整个 cache line 。如果成功,则根本不需要内存读取操作。对于 x86和 x86-64体系结构,gcc 提供了许多内部特性:
#include <emmintrin.h>
void _mm_stream_si32(int *p, int a);
void _mm_stream_si128(int *p, __m128i a);
void _mm_stream_pd(double *p, __m128d a);
#include <xmmintrin.h>
void _mm_stream_pi(__m64 *p, __m64 a);
void _mm_stream_ps(float *p, __m128 a);
#include <ammintrin.h>
void _mm_stream_sd(double *p, __m128d a);
void _mm_stream_ss(float *p, __m128 a);
如果这些指令一次性处理大量数据,则效率最高。数据从内存加载,在一个或多个步骤中处理,然后写回内存。数据“流”通过处理器,因此函数得名“stream”。
内存地址必须分别对齐8或16字节。在支持 MMX 的编译器中,mm_store* 函数可以被编译成 non-temporal 指令。
处理器的写合并 buffer 只能将部分写入到 cache line 的请求保存一段时间。通常来说,需要指令连续,这些指令一个接一个地修改单个 cache line ,以便实际进行写合并。这方面的一个例子如下:
#include <emmintrin.h>
void setbytes(char *p, int c)
{
__m128i i = _mm_set_epi8(c, c, c, c,
c, c, c, c,
c, c, c, c,
c, c, c, c);
_mm_stream_si128((__m128i *)&p[0], i);
_mm_stream_si128((__m128i *)&p[16], i);
_mm_stream_si128((__m128i *)&p[32], i);
_mm_stream_si128((__m128i *)&p[48], i);
}
// https://godbolt.org/z/T46z8hnfc
假设指针 p 对齐到正确的地址,对此函数的调用将把 cache line 的所有字节(64B)设置为 c。写合并逻辑将看到四条生成的 movntdq 指令,并且只在最后一条指令执行完毕后才发出内存的 write 命令。总之,这个代码序列不仅可以避免在写入 cache line 之前读取 cache line,还避免了用可能不需要的数据污染 cache line。在某些情况下,这会带来巨大的好处。使用这种技术的日常代码的一个例子是 C 运行时中的 memset 函数,它应该对大块使用类似上面的代码序列。
有些体系结构提供专门的解决方案。PowerPC 体系结构定义了 dcbz 指令,可用于清除整个 cache line。该指令并没有真正绕过缓存,因为其为结果分配了 cache line,但是不会从内存中读取数据。它比 non-temporal 指令更有限,因为 cache line 只能设置为全零,并且它会污染缓存(如果数据是 non-temporal 的) ,但是不需要写合并逻辑来达到目标。
MOVNTQ — Store of Quadword Using Non-Temporal Hint 使用 Non-Temporal Hint 将源操作数(第二个操作数)中的四字移动到目标操作数(第一个操作数,以最大限度地减少写入内存期间的缓存污染。 在将数据写入内存时,使用写合并(WC)内存类型协议实现 Non-Temporal Hint。使用此协议,处理器不会将数据写入缓存层次结构,也不会从内存中提取相应的缓存行到缓存层次结构中。
什么是 Non-temporal 操作?简单说就是不符合时间局部性的操作 (data will be referenced once and not reused in the immediate future)。 x86 提供了类似 movntdq 的指令,能够以最小化缓存污染的方式将 Non-temporal 数据写入内存,但这只是 hint ,这些 hint 也可以被忽略。 写合并是实现 Non-temporal 操作的方式,写组合(WC)用于允许数据被组合并临时存储在缓冲区(写组合缓冲区(WCB))中,以便稍后以突发模式一起释放,而不是(立即)作为单个位或小块写入15。 Non-temporal 操作约等同于写合并+ no-write-allocate + no-fetch-on-write-miss 16。
为了了解 Non-Temporal 指令的作用,我们将看一个新的测试,该测试用于测量对矩阵的写入,矩阵被组织为二维阵列。编译器在内存中布置矩阵,以便最左边的(第一个)索引寻址具有在内存中顺序布置的所有元素的行。右侧(第二个)索引寻址行中的元素。测试程序以两种方式对矩阵进行迭代:行优先访问和列优先访问。
我们测量初始化3000×3000矩阵所需的时间。
对于使用缓存的普通写入,我们看到了预期的结果:如果按顺序使用内存(行优先),我们会得到更好的结果,与或多或少的随机访问(列优先)相比,行优先比列优先快 11 倍。
我们在这里主要感兴趣的部分是绕过缓存的写入。 行优先,Non-temporal 写入略快于普通写入,快了 1.9 倍。 列优先,情况有所不同。结果明显慢于经过缓存的普通访问的情况(是普通列访问的 3.3 倍)。在这里我们可以看到不可能进行写合并,因为相邻操作的内存地址不在同一 cache line 中,每个存储单元都必须单独寻址。这需要不断地选择 RAM 芯片中的新行,并伴随所有相关的延迟。
在读取方面,带有 SSE4.1 扩展的 Intel 引入了 NTA(Non-temporal access) load。它们是使用少量的流加载缓冲区实现的;每个缓冲区包含一个 cache line 。 movntdqa 会将 cache line 加载到其 stream 缓冲区中,这可能会替换另一个 cache line 。对同一 cache line 的后续访问(要求 16 字节对齐)将从加载缓冲区以很小的成本提供服务。除非有其他原因,否则不会将 cache line 加载到 cache 中,从而可以在不污染 cache 的情况下加载大量内存(这里 cache line 为单位,可能等于 64 B)。编译器为此指令提供了一个内在函数:
该函数应被连续多次调用,将 16 字节块的地址作为参数传递,直到 cache line 都被读完,然后再去读下个 cache line 。由于流式读取缓冲区不止一个,因此可以同时从两个内存位置读取。
MOVNTDQA — Load Double Quadword Non-Temporal Aligned Hint 如果内存源是 WC(写组合)内存类型[…],则使用 Non-temporal hint 将双四字从源操作数(第二个操作数)加载到目标操作数。 […]处理器不会将数据读取到缓存层次结构中,也不会将相应的 cache line 从内存提取到缓存层次中。
我们应该从这个实验中得到的是,现代 CPU 非常好地优化了未缓存的写和 (最近)读访问 ,只要它们是连续的。当处理仅使用一次的大型数据结构时,这些知识非常有用。第二,缓存可以帮助掩盖随机内存访问的部分但不是全部成本。由于 RAM 访问的实现,本例中的随机访问速度慢了 11 倍。在实现更改之前,应尽可能避免随机访问。
Cache 访问¶
程序员可以对缓存进行的最重要的改进是那些影响1级缓存的改进。在包括其他级别的 Cache 之前,我们将先讨论它。显然,对 L1 Cache 的所有优化也会影响其他缓存。所有内存访问的主题都是相同的:改进局部性(空间和时间)并对齐代码和数据。
优化 L1 Cache 访问¶
在本节中,我们将展示哪些类型的代码更改可以帮助提高性能。继续前面的部分,我们首先关注顺序访问内存的优化。当按顺序访问内存时,处理器会自动预取数据。
使用的示例代码是矩阵乘法。我们使用两个1000×1000 double 方阵。
一个简单的 C 实现可以是这样的:
for (i = 0; i < N; ++i)
for (j = 0; j < N; ++j)
for (k = 0; k < N; ++k)
res[i][j] += mul1[i][k] * mul2[k][j];
两个输入矩阵是 mul1和 mul2。假设结果矩阵 res 被初始化为所有零。这是一个漂亮而简单的实现。但是,当 mul1被顺序访问时,内部循环增加 mul2的行数。这意味着 mul1的处理方式是行优先访问,mul2 是列优先访问(近似于随机访问),这可不好。
有一种可能的补救办法。由于矩阵中的每个元素都被多次访问,因此在使用第二个矩阵 mul2之前,可能需要重新排列(用数学术语“转置”)。
转置后(传统上用上标‘ T’表示) ,我们现在按顺序迭代两个矩阵。就 C 代码而言,它现在看起来是这样的:
double tmp[N][N];
for (i = 0; i < N; ++i)
for (j = 0; j < N; ++j)
tmp[i][j] = mul2[j][i];
for (i = 0; i < N; ++i)
for (j = 0; j < N; ++j)
for (k = 0; k < N; ++k)
res[i][j] += mul1[i][k] * tmp[j][k];
我们创建一个临时变量来包含转置矩阵。这需要更多的内存,但由于每列1000次非顺序访问更昂贵(至少在现代硬件上如此)。
上图来源:见https://quick-bench.com/q/6AjzV23SWKj5125Z9WulIaEST-Q
通过简单的矩阵变换我们快了 1.4 倍(降低优化等级还能快的更多)。1000 次非顺序访问真的很痛苦。
无论如何,我们当然需要一种不需要额外副本的替代方法。我们并不总是能够执行复制:矩阵可能太大或可用内存太小。
寻找替代实现应该从仔细检查所涉及的数学和原始实现执行的操作开始。简单的数学知识可以让我们看到,只要每个加数恰好出现一次,对结果矩阵的每个元素执行加法的顺序是无关紧要的。{ 我们在这里忽略可能改变上溢、下溢或舍入发生的算术效应。 这种理解使我们能够寻找重新排序在原始代码的内部循环中执行的加法的解决方案。
现在让我们检查原始代码执行中的实际问题。mul2 的元素被访问的顺序是:(0,0), (1,0), …, (N-1,0), (0,1), (1,1), …。元素 (0,0) 和 (0,1) 在同一个缓存行中,但是当内部循环完成一轮时,该缓存行早已被驱逐。对于这个例子,对于三个矩阵中的每一个,内循环的每一轮都需要 1000 个缓存行(Core 2 处理器为 64 字节)。这加起来远远超过可用的 32k L1d。
但是如果我们在执行内部循环时同时处理中间循环的两次迭代呢? 在这种情况下,我们使用来自缓存行的两个 double 值,这些值保证在 L1d 中。我们将 L1d miss 率降低了一半。这当然是一种改进,但是,根据缓存行的大小,它可能仍然还不够好。Core 2处理器的 L1d 高速缓存行大小为64字节,实际值可以通过运行时使用 sysconf (_SC_LEVEL1_DCACHE_LINESIZE) 或者命令行工具 getconf LEVEL1_DCACHE_LINESIZE 获得,程序可以根据特定的高速缓存行大小进行编译。sizeof(double)是8个字节的情况下,充分利用缓存行,我们应该对中间循环展开8次。继续这个思路,为了有效地使用 res 矩阵,即同时写8个结果,我们也应该展开外循环8次。我们假设缓存行的大小是64B,但是代码在32字节缓存行的系统上也可以很好地工作,因为两条高速缓存行也都被100%使用。代码如下:
// gcc -DCLS=$(getconf LEVEL1_DCACHE_LINESIZE) ...
#define SM (CLS / sizeof (double))
for (i = 0; i < N; i += SM)
for (j = 0; j < N; j += SM)
for (k = 0; k < N; k += SM)
for (i2 = 0, rres = &res[i][j],rmul1 = &mul1[i][k];
i2 < SM; ++i2, rres += N, rmul1 += N)
for (k2 = 0, rmul2 = &mul2[k][j];
k2 < SM; ++k2, rmul2 += N)
for (j2 = 0; j2 < SM; ++j2)
rres[j2] += rmul1[k2] * rmul2[j2];
这看起来很吓人。在某种程度上,它的确如此,但仅仅是因为它包含了一些技巧。最明显的变化是我们现在有6个嵌套循环。外循环以 SM 为间隔进行迭代(缓存行大小除以 sizeof(double))。这将乘法分为几个较小的问题,可以用更多的缓存局部性来处理。内部三个循环遍历外部三个循环间隔部分的索引。这里唯一棘手的部分是 k2和 j2循环的顺序不同。这样做是因为在实际计算中,只有一个表达式取决于 k2,而两个表达式取决于 j2。
这里的其他复杂性来自这样一个事实:gcc 在优化数组索引方面不是很聪明。额外变量 rres、rmul1和 rmul2的引入通过将公共表达式从内部循环中尽可能地提取出来优化代码。C 和 c++语言的默认别名规则不能帮助编译器做出这些决定(除非使用了 restrict 如何理解C语言关键字restrict?,否则所有指针访问都是潜在的别名来源)。这就是为什么 Fortran 仍然是数字编程的首选语言:它使编写快速代码更容易。从理论上讲,1999年修订版中引入的 restrict 关键字应该可以解决这个问题。然而,编译器还没有赶上来。主要原因是现存的错误代码太多,会误导编译器,导致编译器生成错误的目标代码。
作者这里提到的是 Strict Aliasing Rule,具体可以见 严格别名(Strict Aliasing)规则是什么,编译器为什么不做我想做的事?
通过避免复制,相比转置,我们又快了 1.8 倍。另外,我们不需要任何额外的内存。只要结果矩阵也适合内存,输入矩阵就可以任意大。这是我们现在实现的普遍解决办法的要求。
现在,大多数现代处理器都包含对向量化(Vectorized)的特殊支持。通常被称为多媒体扩展,这些特殊指令允许同时处理2、4、8或更多的值。这些操作通常是 SIMD(单指令,多数据)操作,通过其他操作来获得正确形式的数据。英特尔处理器提供的 SSE2指令可以在一次操作中处理两个 double 类型的值。指令参考手册列出了使用这些 SSE2指令的内部函数。具体在后文会提到。
应该注意的是,在最新版本的代码中,mul2仍然存在一些缓存问题。预取仍然无法进行。但是,如果不对矩阵进行转置,这是无法解决的。也许缓存预取单元会变得更聪明以识别我们的访问模式,那么就不需要进行其他更改。
在矩阵乘法示例中,我们优化的方法是使用已加载的缓存行。已加载缓存行的所有字节都被用到。我们只是确保在逐出缓存行之前就使用它们。这当然是个特例。
事实上,更常见的情况是,数据结构占据一个或多个高速缓存行,且程序运行时在任意时间点只使用其中的几个成员。
上图显示了一组基准测试的结果,这个基准使用了目前广泛使用的程序,我们将一个列表中的两个元素值进行相加。第一种情况是两个元素都在同一个缓存行中;另一种情况,一个元素位于列表元素的第一行缓存中,第二个元素位于最后一个缓存行中。该图显示了我们记录下的性能下降程度。
毫不奇怪,当 L1d 能容纳工作集时,在所有情况下都不会产生负面影响。而一旦 L1d 不再足够,将在处理中使用两条高速缓存行而不是一条,这会带来性能下降惩罚。 红线表示测试数据列表在内存中顺序排列时的测试数据。我们看到了通常的两步模式:使用 L2高速缓存时大约17%的性能损失,而必须使用内存时大约27%的性能损失。
随机内存访问测试用例的测试结果看起来有些不同。随机访存用例下,使用 L2高速缓存的工作集的性能相比 L1降低了25%至35%。在这之后,它性能下降的幅度降低到大约10%。这不是因为性能惩罚变小了,而是因为实际内存访问的变得格外昂贵。测试结果还表明,在某些情况下,元素之间的距离也会对性能产生影响。Random 4 CLs 曲线显示出更高的惩罚,因为一个元素位于第一 cache line,一个位于第四 cache line ,距离更远了。
使用工具 pahole 可以知道结构体的内存布局,关于结构体对齐,字节对齐等内容,请见文章字节对齐与填充(Data Alignment and Padding In C)。
本节前面提到的 MMX 几乎总是要求对齐内存访问。即内存地址应该是16字节对齐的。x86和 x86-64处理器的内存操作的支持处理未对齐的访问,但速度较慢。在大多数 RISC 体系结构中,要求对所有内存的访问都是完全对齐的,上述的严苛对齐要求对于它们来说并不是什么新鲜事。即使架构支持不对齐的访问,这有时也比使用适当的对齐要慢,特别是如果不对齐导致装入或存储使用两条缓存行而不是一条缓存行。
上图显示了未对齐内存访问的影响。我们的测试方法是,(顺序或随机)访问内存中的列表数据元素,并增加数据元素的值,先测试列表元素对齐时的性能,再测试列表元素故意不对齐时的性能。该图显示了由于未对齐访问而导致的程序速度下降。对于顺序访问的情况,其影响要比对随机情况的影响更为显着,因为随机访问的内存访问开销太大了,一定程度上掩盖了未对齐访问的开销。在顺序情况下,对于需要使用 L2高速缓存的工作集大小,性能降低了大约300%,这是因为此时 L1缓存的有效性降低了。现在,某些增加操作涉及两条高速缓存行,并且在列表元素上工作通常需要读取两条高速缓存行。L1和 L2之间的连接太拥挤了。
根据我自己的测试,非对齐的内存访问并没有十分明显的性能劣势。原文写于 2007 年,具体的性能测试部分多少有些过时。
对于大型工作集,尽可能使用可用的缓存非常重要。为此,可能需要重新安排数据结构。虽然程序员更容易将概念上属于同一类的所有数据放到同一个数据结构中,但这可能不是实现最佳性能的方法。假设我们的数据结构如下:
进一步假设这些记录存储在一个很大的数组中,并且有一个频繁运行的任务计算所有未支付的账单的总金额。在这种情况下,buyer 和 buyer_id 字段的内存应该不必加载到缓存中。
更好的方法是将订单数据结构一分为二,前两个字段存储在一个结构中,其他字段存储在另一个结构中。这种变化肯定会增加程序的复杂性,但是性能的提高可能能够证明这一成本是合理的。
优化 L1 Instruction Cache 访问¶
为优化 L1i 的使用需要用到优化 L1d 类似的技术。但问题是,除非程序员用汇编语言编写代码,否则程序员通常不能直接影响 L1i 的使用方式。如果使用编译器,程序员还是可以通过引导编译器创建更好的代码布局来间接确定 L1i 的使用。
代码相比于数据有一个优势是代码在发生跳转(jump)之间是线性存储的。处理器可以有效地预取指令,但跳转会扰乱这番和谐的场景,因为:
- 跳转的目标可能不能被静态确定;
- 即便可以静态确定跳转目标,如果所有缓存都未命中,那么获取内存可能需要很长时间。
这些问题会在执行过程中造成停顿,并可能严重影响性能。这就是为什么当今的处理器在分支预测(BP,branch prediction)上投入大量资金进行研发的原因。高度专业化的 BP 单元试图在跳转发生之前,尽可能早的时间确定跳转的目标,以便处理器可以开始将新位置的指令加载到缓存中。它们使用静态和动态规则,并且越来越善于确定执行模式。
对于指令高速缓存而言,尽快将数据放入高速缓存更为重要。在执行指令之前必须先对其进行解码,并且为了加快执行速度(在 x86和 x86-64上非常重要),指令在高速缓存中实际上以已解码形式,而不是以内存中读出来的字节或字(byte/word)形式存放。
为了实现最佳的 L1i 使用,程序员至少应注意代码生成的以下三个方面:
- 尽可能减少代码占用量。这个与诸如循环展开和内联(inlining)之类的优化方法存在 trade off。
- 代码执行应该是线性的,没有 bubbles 。{bubble 形象的描述了处理器流水线(pipeline)在执行过程中的遇到的空洞(hole),当执行必须等待资源时会出现这些空洞。有关详细信息,请参阅有关处理器设计的文献。
- 在适当的时候对齐代码。
下面我们来看一些可从这三个方面帮助程序优化的编译器技术。
编译器具有一个选项用来选择优化级别,特定的优化级别也可以单独使用。在高优化级别(比如 gcc 为-O2和-O3)启用的许多优化都涉及循环优化和函数内联。通常,这些优化都很好。如果以这些方式优化的代码占程序总执行时间的很大一部分,则可以提高整体性能。特别地,函数内联允许编译器一次优化更大的代码块,从而可以使生成的机器代码更好地利用处理器的流水线体系结构。当可以将程序的较大部分视为一个单元时,对代码和数据的处理(通过消除无效代码或值域传播以及其他方法)效果更好。
更长的代码意味着对 L1i(以及 L2和更高级别)高速缓存的压力更高。这会导致性能降低。更短的代码可以运行得更快。幸运的是,gcc 有一个优化选项可以指定此项。如果使用-Os,则编译器将优化代码大小。使用后,能够增加代码大小的哪些优化将被禁用。使用此选项通常会产生令人惊讶的结果。特别是循环展开和内联没有实质优势时,那么此选项将是一个很好的选择。
内联会增大代码的大小,又是不利于 L1i。但是,在某些情况下,内联总是有意义的。如果一个函数仅被调用一次,则最好内联。这使编译器有机会执行更多优化(例如值域传播,这可能会大大改善代码)。在这种情况下,gcc 具有一个选项,用于指定函数始终作为内联函数。给函数添加 always_inline 函数属性可使编译器的行为完全按照这个属性的名字描述的那样。
在相同的上下文中,如果函数在足够小时也不想使用内联,则可以使用 noinline 函数属性。如果函数经常从不同地方调用,那么对小型函数使用此属性也是有意义的。如果可以重复使用 L1i 内容并且整体代码占用空间减小了,那么这通常可以弥补不使用内联带来的额外函数调用开销。
如今,分支预测单元的效果已经非常好了。如果内联可以导致更积极的优化,则情况将有所不同。这是必须根据具体情况决定的。
如果始终使用内联代码,则 always_inline 属性可以很好地工作。但是,如果不是这种情况怎么办? 如果仅偶尔调用内联函数会怎么样呢:
通常编译器生成代码的顺序与源代码的结构匹配。这意味着首先是代码块 A,然后是条件跳转,如果条件为假,则条件跳转将向前跳转。接下来是为内联的 inlfct 函数生成的代码,最后是代码块 C。在着起来很合理的行为含有隐藏的问题。
当 condition 经常为假时,执行过程就不是线性的了。中间有很多未使用的代码,这不仅会在预取时污染 L1i,而且还会引起分支预测的问题。如果分支预测错误,则条件表达式的效率可能非常低。
这是一个普遍的问题,并不仅限于使用内联函数时。每当使用条件语句且条件选择不均衡时(即该表达式更容易选择一遍执行),就有可能导致错误的静态分支预测,从而在流水线中产生气泡。这个问题可以通过让编译器将执行频率较低的代码移出主代码路径来预防。在这种情况下,为 if 语句更不容易选择的路径生成的条件分支将跳到主路径顺序之外的位置。
图的上面部分代表通常情况下的简单代码布局。如果区域 B(这里是内联函数 inlfct 生成的代码)经常由于条件 I 被跳过,而不会执行,处理器的预取将拉入很少使用的包含块 B 的高速缓存行。使用块重新排序可以改变这种局面,改变之后的效果可以在图的下半部分看到。经常执行的代码在内存中是线性的,而很少执行的代码被移动到不会损害预取和 L1i 效率的位置。
一种方法是显示分支预测,gcc 提供了 __builtin_expect:
long __builtin_expect(long EXP, long C);
#define unlikely(expr) __builtin_expect(!!(expr), 0)
#define likely(expr) __builtin_expect(!!(expr), 1)
if (likely(a > 1))
...
内联不是关于 L1i 优化的唯一方面。另一个方面是对齐,就像数据一样。和数据不同的是,代码是一个主要为线性分布的二进制类型大对象(Blob,Binary Large Object),不能随意放置在地址空间中,也不能在编译器生成代码时受到程序员的直接影响。不过,也有一些方面是程序员可以控制的。
对齐每条指令是没有意义的。我们的目的是使指令流是顺序执行的。因此,在关键的地方进行对齐才有意义。要决定在什么地方进行对齐,必须了解对齐会带来什么优势。在高速缓存行的开头放置指令{对于某些处理器,高速缓存行不是指令的原子块。英特尔酷睿2前端向解码器发出16个字节的块。它们已适当对齐,因此没有已发布的块可以跨越高速缓存行边界。在高速缓存行的开头对齐仍然具有优势,因为它可以优化预取的积极效果。}意味着可以最大化高速缓存行的预取。对于指令,这也意味着解码器更有效。不难看出,如果执行了高速缓存行末尾的指令,则处理器必须准备好读取新的高速缓存行并对指令进行解码。有些事情可能出错(例如高速缓存行未命中),这意味着平均而言,高速缓存行末尾的指令执行效率不如高速缓存行开头的有效。
编译器通过在由对齐方式产生的空白中插入一系列空指令(no-op instructions)来实现对齐。这些“无效代码(dead code)”会占很小的空间,通常不会影响性能。
如果代码是用汇编写的,则函数及其中的所有指令都可以显式对齐。汇编程序为所有体系结构提供 .align 伪操作(pseudo-op)来实现对齐。对于高级语言,必须告知编译器对齐要求。与数据类型和变量不同,这在源代码中是不可能的。而是使用编译器选项 -falign-functions=N
此选项指示编译器将所有函数与其下一个大于 N 的2的幂的边界对齐。这意味着将创建最多 N 个字节的间隙。对于较小的函数或很少用到的代码,使用较大的 N 值是浪费的。库中经常会有一些接口是频繁使用的,而另一些是很少使用的。合理的选择这个配置项的值可以避免对齐,从而加快运行速度和节省内存。当 N 的值为1或使用 -fno-align-functions 选项时,编译器将不进行对齐。
优化 L2/L3 Cache¶
上面描述的所有对于 L1 Cache 的优化方法也适用于2级以及更高层级的缓存。对于最后一级缓存,还有额外的两个方面:
- 高速缓存未命中总是非常昂贵的。一级缓存未命中时我们希望可以频繁地命中L2和更高的缓存,从而限制性能惩罚,但是最后一级缓存显然是没有后备选择的。
- L2高速缓存和更高级别的高速缓存通常由多个内核和/或超线程共享。因此,每个执行单元可用的有效缓存大小通常小于总缓存大小。
优化 TLB¶
TLB 用法有两种优化。第一个优化是减少程序必须使用的页面数量。这样自然会减少 TLB 的脱靶。第二个优化是通过减少必须分配的更高级别目录表数量来降低 TLB 查找的成本。更少的表意味着使用更少的内存,自然而然目录查找具有更高缓存命中率。
第一个优化与最小化页错误密切相关。页错误通常是一次性的开销,然而,因为 TLB 缓存通常很小,且经常刷新,因此 TLB 失败则是永久的代价。页错误的代价比 TLB 脱靶代价大,但是,如果一个程序运行的时间足够长,并且程序的某些部分执行得足够频繁,那么 TLB 脱靶甚至可能超过页错误成本。因此,重要的是不仅要从页错误的角度,而且要从 TLB 脱靶的角度来考虑页面优化。不同之处在于,页错误优化只需要对代码和数据进行页对齐,而 TLB 优化在任何时候都需要尽可能少地使用 TLB 条目。可以考虑使用 Huge Page。
第二个 TLB 优化更难控制。必要的页目录数量取决于进程的虚拟地址空间中使用的地址范围的分布。地址空间中不同的位置意味着更多的目录。一个复杂的问题是地址空间布局随机化(ASLR)导致了这些情况。堆栈、DSOs、堆和可能的可执行文件的加载地址在运行时被随机化,以防止机器攻击者猜测函数或变量的地址。
预取¶
预取的目的是为了隐藏内存访问的延时。pipeline 和无序执行(OOO,Out of order)如今的处理器虽然有能力隐藏一部分延时,但最多也仅仅是对命中缓存的访问来说。为了覆盖主内存访问的延迟,pipeline 必须非常长。一些没有 OOO 的处理器试图通过增加内核数量来进行补偿,但这是一个糟糕方法,除非所有正在使用的代码都是并行的。
预取可以进一步帮助隐藏延迟。处理器自己执行预取,由某些事件触发(硬件预取)或由程序显式请求(软件预取)。
硬件预取¶
软件预取¶
软件预取确实需要通过插入特殊指令来修改源代码。一些编译器支持 pragmas,或多或少地自动插入预取指令。在 x86和 x86-64上,通常使用英特尔的编译器内含物惯例来插入这些特殊指令。
#include <xmmintrin.h>
/// Loads one cache line of data from the specified address to a location
/// closer to the processor.
///
/// \headerfile <x86intrin.h>
///
/// \code
/// void _mm_prefetch(const void *a, const int sel);
/// \endcode
///
/// This intrinsic corresponds to the <c> PREFETCHNTA </c> instruction.
///
/// \param a
/// A pointer to a memory location containing a cache line of data.
/// \param sel
/// A predefined integer constant specifying the type of prefetch
/// operation: \n
/// _MM_HINT_NTA: Move data using the non-temporal access (NTA) hint. The
/// PREFETCHNTA instruction will be generated. \n
/// _MM_HINT_T0: Move data using the T0 hint. The PREFETCHT0 instruction will
/// be generated. \n
/// _MM_HINT_T1: Move data using the T1 hint. The PREFETCHT1 instruction will
/// be generated. \n
/// _MM_HINT_T2: Move data using the T2 hint. The PREFETCHT2 instruction will
/// be generated.
void _mm_prefetch(void *p,int _mm_hint);
程序员可以对程序中的任何指针使用 _mm_prefetch。大多数处理器(当然是所有的 x86和 x86-64处理器)都会忽略无效指针导致的错误。但是,如果传递的指针引用了有效的内存,预取单元将被指示将数据加载到高速缓存中,如果有必要,还将驱逐其他数据。不必要的预取肯定是要避免的,因为这可能会降低缓存的有效性,而且会消耗内存带宽。
结合上下文,作者定义“低级别缓存”是 L1, “高级别缓存” 为 L3
_mm_prefetch 第二个参数是由实现定义的。这意味着每个处理器版本可以以不同的方式实现它们(略)。
通常可以说的是,_MM_HINT_T0 对于 inclusive 的缓冲区,将数据取到所有级别的缓冲区;对于 exclusive 的缓冲区,将数据取到最低级别的缓冲区,如果数据项在更高层次的高速缓存中,它将被加载到 L1d 中。
_MM_HINT_T1 hint 将数据拉到 L2而不是 L1d。如果存在 L3缓存,_MM_HINT_T2 hint 也做做的是类似的事情。不过这些都是细节问题,规定得很弱,需要对实际使用的处理器进行验证。
一般来说,如果数据要马上被使用,使用 _MM_HINT_T0 是正确的做法。当然这需要 L1d 缓存的大小足以容纳所有预取的数据。如果立即使用的工作集的大小太大,将所有数据预取到 L1d 中是个坏主意,应该使用其他两个 hint 。
第四个 hint ,_MM_HINT_NTA,很特别,它允许告诉处理器特别对待预取的缓存行。NTA 代表 non-temporal aligned ,我们在第6.1节已经解释过。该程序告诉处理器应该尽可能的避免用这些数据污染缓存,因为这些数据只用了很短的时间。因此,在加载时,对于 inclusive 的缓存实现,处理器应避免将数据读入低级别的缓存(L1)。当数据从 L1d 中被驱逐时,数据不需要被推入 L2或更高的级别,而是可以直接写入内存。
程序员必须小心使用这个提示:如果当前工作集的大小太大,并强制驱逐用 NTA 提示加载的缓存行,就会发生从内存重新加载的情况。
Speculation¶
Helper Threads¶
Direct Cache Access¶
现代操作系统中 Cache miss 的一个来源是对传入数据流量的处理。现代硬件,如网络接口卡 (NIC) 和磁盘控制器,能够在不涉及 CPU 的情况下将接收到的数据或读取的数据直接写入内存 (DMA, Direct Memory Access)。这对我们今天拥有的设备的性能至关重要,但它也会带来问题。假设有一个来自网络的传入数据包:操作系统必须通过查看数据包的标头来决定如何处理它。网卡将数据包放入内存,然后将数据包到达通知处理器。处理器没有机会预取数据,因为它不知道数据何时到达,甚至可能不知道数据将存储在何处。结果是读取标头时缓存未命中。
Intel 在他们的芯片组和 CPU 中增加了技术来缓解这个问题 [direct cache access]。这个想法是用 packet 填充 CPU 的缓存。packet 的 payload 在这里并不重要,通常,payload 将由内核或用户级别的更高级别的功能处理,与处理器关系不大。但 packet 标头用于决定数据包的处理方式,因此立即需要此数据。
直接缓存访问 (DCA) 背后的想法是扩展 NIC 和内存控制器之间的协议。
第一个图显示了在具有北桥和南桥的常规机器中 DMA 传输的开始。传统的行为是,简单地完成与内存连接的 DMA 传输。
对于设置了 DCA 标志的 DMA 传输,北桥另外发送带有特殊新 DCA 标志的 FSB 上的数据。处理器总是侦听 FSB,如果它识别出 DCA 标志,它会尝试将指向处理器的数据加载到最低级缓存中。实际上,DCA 标志是一个 hint ;处理器可以随意忽略它。DMA 传输完成后,处理器收到信号。
NIC 连接到(或属于)南桥。它启动 DMA 访问,但提供有关应放到处理器缓存的 packet 的标头的信息。
操作系统在处理数据包时,首先必须确定它是什么类型的数据包。将每个数据包节省的数百个周期与每秒可处理的数万个数据包相乘,节省的总和将是非常可观的数字,尤其是在延迟方面。
程序员还可以做什么¶
多线程优化¶
谈到多线程时,缓存使用的三个不同方面很重要:
- 并发
- 原子性
- 带宽
此上下文中的并发是指进程在一次运行多个线程时所经历的内存效应。线程的一个属性是它们都共享相同的地址空间,因此都可以访问相同的内存。在理想情况下,线程使用的内存区域是不同的,在这种情况下,这些线程只是轻微耦合(例如,公共输入和/或输出)。如果多个线程使用相同的数据,则需要协调;这是原子性发挥作用的时候。
最后,根据机器架构,处理器可用的内存和处理器间总线带宽是有限的。我们将在以下各节中分别处理这三个方面——尽管它们当然是密切相关的。
并发优化¶
在本小节中,我们将讨论两个独立的问题,这两个问题实际上需要相互矛盾的优化。 多线程应用程序在其某些线程中使用公共数据。正常的缓存优化要求将数据保存在一起,以便应用程序的占用空间很小,从而使缓存能缓存更多的内容。
但是,这种方法存在一个问题:如果多个线程写入内存位置,则缓存行必须在每个相应核心的 L1d 中处于“E”(exclusive)状态。这意味着会发送大量 RFO 请求,在最坏的情况下,每次写访问都会发送一个请求(MOESI 协议)。所以正常的写入会突然变得非常昂贵。如果使用相同的内存位置,则需要同步(可能通过使用原子操作,这将在下一节中处理)。
上图显示了这种“False sharing”的结果。测试程序创建了一些线程,它们除了递增内存位置(5 亿次)外什么都不做。测量的时间是从程序开始到等待最后一个线程后程序结束。线程固定到各个处理器。该机器有四个 P4 处理器。 蓝色值表示分配给每个线程的内存分配位于单独的 Cache line 上的运行时间。 红色部分是将每个线程的内存位置位于同一个 Cache line 时发生的时间惩罚。
蓝色测量值(当使用单独的缓存行时)符合人们的预期。该程序可以很好的使用多线程进行扩展。每个处理器都将其缓存行保存在自己的 L1d 中,并且没有带宽问题,因为无需读取太多代码或数据(实际上,它们都被缓存了)。测得的轻微增加实际上是系统噪声和一些预取效应(线程使用顺序缓存行)。
通过将每个线程使用相同的一个缓存行与单独的缓存行所需的时间相除计算得出的测量开销分别为 390%、734% 和 1,147%。这些大数字乍一看可能会令人惊讶,但在考虑所需的缓存交互时,它应该是显而易见的。
这个 Cache line 在完成写入后立即从一个处理器的缓存中逐出。在任意时刻,仅拥有该 Cache line 所有权的某个处理器可以进行操作,而其他所有处理器都被延迟并且无法执行任何操作。每个额外的处理器只会导致更多的延迟。
处理器修改位于同一 Cache line 的不同内存位置时,硬件要保证一个处理器进行的修改能被其他处理器看见,即保证缓存一致性,但由此也引发了 False sharing.
这个问题有一个非常简单的“修复”方法:将每个变量放在它自己的缓存行中。这就是与前面提到的优化冲突的地方,具体来说,应用程序的占用空间会增加很多。这是不可接受的;因此,有必要想出一个更智能的解决方案。
需要做的是识别哪些变量一次只被一个线程使用,哪些变量曾经只被一个线程使用,以及那些可能有时会被争用的变量。对于这些场景中的每一个,不同的解决方案都是可能且有用的。区分变量的最基本标准是:它们是否曾经被写入过以及这种情况发生的频率。
从未写入的变量和仅初始化一次的变量基本上是常量。由于 RFO 请求仅用于写操作,常量可以在缓存中共享(“S”状态)。所以,这些变量不必特殊对待;将它们组合在一起很好。如果程序员用 const 正确标记变量,工具链会将变量从普通变量移到.rodata(只读数据)或.data.rel.ro(重定位后只读)部分 { Sections,由它们的名称标识的是包含 ELF 文件中的代码和数据的原子单元。} 不需要其他特殊操作。如果由于某种原因,变量不能用 const 正确标记, 程序员可以通过将它们分配到一个特殊的部分来影响它们的位置。
当链接器构建最终的二进制文件时,它首先从所有输入文件中附加同名的 section ;然后这些部分按照链接描述文件确定的顺序排列。这意味着,通过将所有基本不变但未标记为常量的变量移动到一个特殊的 section ,程序员可以将所有这些变量组合在一起。它们之间不会有经常写入的变量。通过适当对齐该部分中的第一个变量,可以保证不会发生错误共享。假设这个小例子:
int foo = 1;
int bar __attribute__((section(".data.ro"))) = 2;
int baz = 3;
int xyzzy __attribute__((section(".data.ro"))) = 4;
如果编译,这个输入文件定义了四个变量。有趣的部分是变量 foo 和 baz 以及 bar 和 xyzzy 分别组合在一起。如果没有属性定义,编译器将按照它们在源代码中定义的顺序将所有四个变量分配到名为.data 的 section 。{ ISO C 标准不保证这一点,但它是 gcc 的工作方式。} 代码按原样将变量 bar 和 xyzzy 放置在名为.data.ro 的 section 中。section 名称.data.ro 或多或少是任意的。.data 的 section 前缀保证 GNU 链接器将该 section 与其他 data sections 放在一起。
可以应用相同的技术来分离出大部分被读取但偶尔被写入的变量。只需选择一个不同的部分名称。这种分离在某些情况下似乎是有意义的,例如 Linux 内核。
如果一个变量只被一个线程使用,还有另一种方法来指定该变量。在这种情况下,使用 thread local 变量是可能且有用的。gcc 中的 C 和 C++ 语言允许使用 __thread 关键字将变量定义为 per-thread。
变量 bar 和 xyzzy 没有分配在正常的数据段;相反,每个线程都有自己独立的区域来存储这些变量。变量可以有静态初始值设定项。所有线程局部变量都可由所有其他线程寻址,但是,除非线程将指向线程局部变量的指针传递给其他线程,否则其他线程无法找到该变量。由于变量是线程局部的,False sharing 不是问题——除非程序人为地制造问题。这个解决方案易于设置(编译器和链接器完成所有工作),但它有其成本。创建线程时,它必须花费一些时间来设置线程局部变量,这需要时间和内存。此外,
使用线程本地存储 (TLS) 的一个缺点是,如果变量的使用转移到另一个线程,则旧线程中变量的当前值对新线程不可用。每个线程的变量副本都是不同的。通常这根本不是问题,如果是,转移到新线程需要协调,此时可以复制当前值。
第二个更大的问题是可能浪费资源。如果在任何时候只有一个线程曾经使用过该变量,则所有线程都必须在内存方面付出代价。如果一个线程不使用任何 TLS 变量,TLS 内存区域的惰性分配可以防止这成为一个问题(应用程序本身中的 TLS 除外)。如果一个线程在 DSO 中只使用一个 TLS 变量,则该对象中所有其他 TLS 变量的内存也将被分配。如果大规模使用 TLS 变量,这可能会增加。
一般来说,可以给出的最佳建议是:
- 至少将只读变量和读写变量分开。Maybe extend this separation to read-mostly variables as a third category.
- 将一起使用的读写变量放到同一个结构体中。
- 将经常由不同线程写入的读写变量移动到它们自己的 Cache line。这可能意味着在末尾添加填充以填充缓存行的剩余部分。如果与第 2 步相结合,这通常并不是真正的浪费。扩展上面的例子,我们可能会得到如下代码(假设 bar 和 xyzzy 是一起使用的):
#define CLSIZE 64
int foo = 1;
int baz = 3;
struct {
struct al1 {
int bar;
int xyzzy;
};
char pad[CLSIZE - sizeof(struct al1)];
} rwstruct __attribute__((aligned(CLSIZE))) =
{ { .bar = 2, .xyzzy = 4 } };
需要进行一些代码更改(对 bar 的引用必须替换为 rwstruct.bar,对于 xyzzy 也是如此),仅此而已。编译器和链接器完成所有剩下的工作。{此代码必须在命令行上使用 -fms-extensions } 进行编译。}
- 如果一个变量被多个线程使用,但每次使用都是独立的,则将该变量移入 thread-local storage.
原子性优化¶
如果多个线程同时修改同一个内存位置,处理器不保证任何特定的结果。例如,如果内存位置处于“S”状态并且两个线程必须同时增加其值,则执行管道在读取旧值之前不必等待缓存行在“E”状态下可用从缓存中执行添加。相反,它读取当前缓存中的值,一旦缓存行在状态“E”可用,新值将被写回。如果两个线程中的两次缓存读取同时发生,则结果不是预期的;一个添加将丢失。
为确保不会发生这种情况,处理器提供了原子操作。例如,这些原子操作不会读取旧值,直到清楚可以以对内存位置的加法显示为原子的方式执行加法。除了等待其他内核和处理器之外,一些处理器甚至会向主板上的其他设备发出特定地址的原子操作信号。所有这些都使原子操作变慢。
在 X86 上,普遍可用的原子操作可以分为四类:
-
Bit Test
These operations set or clear a bit atomically and return a status indicating whether the bit was set before or not.
-
Load Lock/Store Conditional (LL/SC)
These operations work as a pair where the special load instruction is used to start an transaction and the final store will only succeed if the location has not been modified in the meantime. The store operation indicates success or failure, so the program can repeat its efforts if necessary.
-
Compare-and-Swap (CAS)
This is a ternary operation which writes a value provided as a parameter into an address (the second parameter) only if the current value is the same as the third parameter value;
-
Atomic Arithmetic
These operations are only available on x86 and x86-64, which can perform arithmetic and logic operations on memory locations. These processors have support for non-atomic versions of these operations but RISC architectures do not. So it is no wonder that their availability is limited.
一种体系结构支持 LL/SC 或 CAS 指令,但不能同时支持两者。这两种方法基本上是等价的;它们同样可以很好地实现原子算术运算,但 CAS 似乎是目前首选的方法。所有其他操作都可以使用它间接实现。例如,一个原子加法:
int curval;
int newval;
do {
curval = var;
newval = curval + addend;
} while (CAS(&var, curval, newval));
CAS 调用的结果表明操作是否成功。如果返回失败(非零值),则循环再次运行,执行加法,并再次尝试 CAS 调用。如此重复直到成功。该代码值得注意的是,内存位置的地址必须在两条单独的指令中计算。{ x86 和 x86-64 上的 CAS 可以避免在第二次和以后的迭代中加载值,但是,在这个平台上,我们可以用更简单的方式编写原子加法,使用单个加法操作}。
对于 LL/SC,代码看起来差不多。
在这里,我们必须使用特殊的加载指令 ( LL ),并且我们不必将内存位置的当前值传递给 SC,因为处理器知道内存位置是否已同时被修改。
最大的区别是 x86 和 x86-64,我们有原子操作,在这里,选择适当的原子操作以获得最佳结果很重要。图 6.12 显示了实现原子增量操作的三种不同方式。
- Add and Read Result
- Add and Return Old Value
for (i = 0; i < N; ++i) {
long v, n;
do {
v = var;
n = v + 1;
} while (!__sync_bool_compare_and_swap(&var, v, n));
}
- Atomic Replace with New Value
这三者在 x86 和 x86-64 上产生不同的代码,而在其他架构上的代码可能相同。存在巨大的性能差异。下表显示了四个并发线程执行 100 万个增量的时间。该代码使用 gcc 的内置原语 ( _sync)。
| 1. Exchange Add | 2. Add Fetch | 3. CAS |
|---|---|---|
| 0.23s | 0.21s | 0.73s |
CAS 操作要更慢。有几个原因:
- 有两个内存操作
- CAS 操作本身比较复杂,甚至需要条件操作
- 整个操作必须在一个循环中进行,以防两个并发访问导致 CAS 调用失败。
现在读者可能会问一个问题:为什么有人会使用利用 CAS 的复杂且较长的代码?答案是:复杂性通常是隐藏的。如前所述,CAS 目前是所有有趣架构的统一原子操作。所以有人认为用 CAS 来定义所有的原子操作就足够了。这使程序更简单。但正如数字所示,结果可能并非最佳。CAS 解决方案的内存处理开销很大。下面说明了两个线程的执行,每个线程都在自己的核心上。
| Thread #1 | Thread #2 | var Cache State |
|---|---|---|
| v = var | ‘E’ on Proc 1 | |
| n = v + 1 | v = var | ‘S’ on Proc 1+2 |
| CAS(var) | n = v + 1 | ‘E’ on Proc 1 |
| CAS(var) | ‘E’ on Proc 2 |
我们看到,在这短短的执行时间内,缓存行状态至少发生了三次变化;其中两项更改是 RFO。此外,第二个 CAS 将失败,因此该线程必须重复整个操作。在该操作期间,同样的情况可能会再次发生。
相反,当使用原子算术运算时,处理器可以将执行加法(或其他)所需的加载和存储操作保持在一起。它可以确保并发发出的缓存行请求被阻塞,直到原子操作完成。因此,示例中的每个循环迭代最多只会产生一个 RFO 缓存请求,而不会产生其他任何结果。
所有这一切意味着,在可以使用原子算术和逻辑操作的级别上定义机器抽象是至关重要的。CAS 不应被普遍用作统一机制。
X86 使用 lock 前缀使汇编操作具有原子性。
带宽考虑¶
每个处理器都有一个通往内存的最大带宽,这个带宽是由该处理器上的所有内核和超线程共享的。根据机器的结构,多个处理器可能会共享到内存或北桥的同一总线。
处理器内核本身的运行频率,在全速运行时,即使在完美的条件下,与内存的连接也无法满足所有的加载和存储请求而无需等待。现在,进一步将可用带宽除以核心、超线程和共享北桥连接的处理器的数量,突然间,并行性变成了一个大问题。理论上非常高效的程序可能受到内存带宽的限制。
程序员必须准备好识别由于带宽有限而产生的问题。
现代处理器的性能测量计数器允许观察 FSB 的竞争情况。在 Core 2处理器上,NUS_BNR_DRV 事件计数了一个内核因为总线没有准备好而必须等待的周期数。这表明总线被高度使用,从主内存加载或存储到主内存的时间甚至比平时更长。Core 2处理器支持更多的事件,可以计算特定的总线动作,如 RFO 或一般 FSB 的利用率。在开发过程中调查应用程序的可扩展性时,后者可能会派上用场。如果总线利用率已经接近1.0,那么扩展的机会就微乎其微了。
如果认识到带宽问题,有几件事可以做。它们有时是相互矛盾的,所以可能需要进行一些试验。一个解决方案是购买更快的计算机。不过,这可能会花费很多。如果有问题的程序只需要在一台(或几台)机器上使用,那么硬件的一次性支出可能比重新制作程序的费用要低。不过,一般来说,最好还是在程序上下功夫。
在对程序本身进行优化以避免缓存缺失后,为了实现更好的带宽利用率,剩下的唯一选择就是将线程更好地放在可用的 core 上。默认情况下,内核中的调度器会根据自己的策略将线程分配给一个处理器。尽可能避免将一个线程从一个核心移到另一个核心。不过,调度器并不真正了解工作负载的情况。它可以从缓存缺失中收集信息,但这在很多情况下是没有什么帮助的。
有一种情况会导致 FSB 的大量使用,那就是当两个线程被安排在不同的处理器(或不共享缓存的内核)上,并且它们使用相同的数据集。上图显示了这样一种情况。核心1和3访问相同的数据(用相同颜色的访问指示器和内存区域来表示)。同样,核心2和4也访问相同的数据。但是这些线程被安排在不同的处理器上。这意味着每个数据集必须从内存中读取两次。
在上图中,我们看到了理想状态下的情况。现在使用的总的缓存大小减少了,因为现在核心1和2以及核心3和4在相同的数据上工作。这些数据集只需要从内存中读取一次。
这是一个简单的例子,但推而广之,它适用于许多情况。如前所述,内核中的调度器并不了解数据的使用情况,所以程序员必须保证调度工作的有效进行。没有很多内核接口可以用来传达这个要求。事实上,只有一个:定义线程亲和力。
线程亲和意味着将一个线程分配给一个或多个 core 。然后,当决定在哪里运行线程时,调度器将在这些 core 中(仅)进行选择。即使其他 core 处于空闲状态,它们也不会被考虑。这听起来可能是个缺点,但这是必须要付出的代价。如果太多的线程只在一组 core 上运行,剩下的 core 可能大部分都是空闲的,除了改变亲和力之外,人们什么也做不了。默认情况下,线程可以在任何 core 上运行。
有许多接口可以查询和更改线程的亲缘关系:
#define _GNU_SOURCE
#include <sched.h>
int sched_setaffinity(pid_t pid, size_t size, const cpu_set_t *cpuset);
int sched_getaffinity(pid_t pid, size_t size, cpu_set_t *cpuset);
#define _GNU_SOURCE
#include <pthread.h>
int pthread_setaffinity_np(pthread_t th, size_t size,
const cpu_set_t *cpuset);
int pthread_getaffinity_np(pthread_t th, size_t size, cpu_set_t *cpuset);
int pthread_attr_setaffinity_np(pthread_attr_t *at,
size_t size, const cpu_set_t *cpuset);
int pthread_attr_getaffinity_np(pthread_attr_t *at, size_t size,
cpu_set_t *cpuset);
到目前为止,我们已经讨论了两个线程的工作集重叠的情况,因此将两个线程放在同一个 core 上是有意义的。反之亦然。如果两个线程在不同的数据集上工作,将它们安排在同一个核心上可能会出现问题。两个线程争夺同一个缓存,从而减少了彼此对缓存的有效使用。其次,两个数据集必须加载到同一个缓存中;实际上,这增加了必须加载的数据量,因此可用带宽减少了一半。
这种情况下的解决方案是设置线程的亲和性,使它们不能被调度到同一个核心上。这与前一种情况相反,因此在进行任何更改之前了解人们试图优化的情况非常重要。
优化缓存共享以优化带宽实际上是 NUMA 编程的一个方面,这将在下一节中介绍。只需将“内存”的概念扩展到高速缓存即可。一旦缓存级别的数量增加,这将变得越来越重要。为此,NUMA 支持库中提供了多核调度的解决方案。
NUMA 编程¶
内存性能工具¶
- oprofile
- opannotate
- cachegrind
- massif
- valgrind
参考¶
-
https://www.linkedin.com/pulse/understanding-physical-logical-cpus-akshay-deshpande ↩
-
http://www.intel.com/content/dam/doc/white-paper/quick-path-interconnect-introduction-paper.pdf ↩
-
cpu 的问题? - 知乎 https://www.zhihu.com/question/57119180/answer/151844417 内存控制器和 ↩
-
https://en.wikipedia.org/wiki/Intel_Ultra_Path_Interconnect ↩
-
https://web.archive.org/web/20160315021718/https://software.intel.com/sites/products/collateral/hpc/vtune/performance_analysis_guide.pdf ↩
-
https://courses.cs.washington.edu/courses/cse351/19sp/lectures/23/code/vm_overview.pdf ↩
-
https://www.intel.com/content/dam/develop/external/us/en/documents/run-perf-opt-bp-large-code-pages-q1update.pdf ↩
-
https://www.intel.com/content/dam/www/public/us/en/documents/white-papers/cache-allocation-technology-white-paper.pdf ↩
-
http://download.intel.com/design/PentiumII/applnots/24442201.pdf ↩
Ended: 每个程序员都应该了解的内存知识
每个系统程序员都应该了解的并发知识 ↵
每个系统程序员都应该了解的并发知识¶
摘要¶
系统程序员熟悉互斥锁、信号量和条件变量等工具。但它们是如何工作的?当我们无法使用它们时,比如在操作系统之下的嵌入式环境中工作,或者由于严格的时间限制而不能阻塞时,我们如何编写并发代码?还有,由于编译器和硬件合谋将你的代码变成了你未写过的东西,按照你从未要求的顺序运行,多线程程序是如何工作的?并发性是一个复杂且直观的话题,但让我们尝试覆盖一些基础知识。
背景¶
现代计算机同时运行许多指令流。在单核机器上,它们轮流共享 CPU 的时间片。在多核机器上,可以同时运行多个指令流。我们可以用不同的名字称呼它们——进程、线程、任务、中断服务例程等——但大多数原则都适用。尽管计算机科学家构建了许多伟大的抽象,但这些指令流(为了简洁起见,我们统称为线程)最终通过共享状态位来交互。为此,我们需要理解线程读写内存的顺序。考虑一个简单的例子,线程 A 与其它线程共享一个整数。它将整数写入某个变量,然后设置一个标志,指示其他线程读取它刚刚存储的值。在代码中,可能类似于:
int v;
bool v_ready = false;
void threadA() {
// 写入值并设置就绪标志。
v = 42;
v_ready = true;
}
void threadB() {
// 等待值变化并读取它。
while (!v_ready) { /* 等待 */ }
const int my_v = v; // 用 my_v 做一些事情...
}
我们需要确保其他线程在 A 写入 v_ready 之后才观察到 A 对 v 的写入。(如果另一个线程能在看到 v_ready 变为 true 之前“看到”v 变为 42,这个简单的方案就不会起作用。)你可能认为保证这个顺序是微不足道的,但事情并非看上去那么简单。首先,任何优化编译器都会重写你的代码,使其在目标硬件上运行得更快。只要生成的指令对当前线程产生相同的效果,读写操作就可以移动以避免流水线停顿或提高局部性。如果它们从未同时使用,可以将变量分配到相同的内存位置。计算可以在分支采取之前进行推测,如果编译器猜测错误,则可以忽略。
即使编译器没有改变我们的代码,我们仍然会有麻烦,因为我们的硬件也这么做!现代 CPU 处理指令的方式比传统的流水线方法要复杂得多,如图 1 所示。它们包含许多数据路径,每条路径用于不同类型的指令,还有调度器,这些调度器重新排序并路由指令通过这些路径。
图 1:一个传统的五阶段 CPU 流水线,包括获取、解码、执行、内存访问和写回阶段。现代设计要复杂得多,经常在现场重新排序指令。图片来自 Wikipedia。
内存的工作原理也很容易做出天真的假设。如果我们想象一个多核处理器,我们可能会想到类似于图 2 的东西,其中每个核心轮流对系统的内存执行读写操作。
图 2:一个理想化的多核处理器,其中核心轮流访问一个共享的内存集合。
但世界并不那么简单。尽管过去几十年处理器速度呈指数级增长,但 RAM 并没有跟上,造成执行指令所需的时间与从内存中检索其数据所需的时间之间的差距不断扩大。硬件设计师通过在 CPU 芯片上直接放置越来越多的层次化的缓存来补偿。每个核心通常还有一个存储缓冲区,在执行后续指令时处理待定的写入。保持这个内存系统一致,使得一个核心所做的写入能够被其他核心观察到,即使这些核心使用不同的缓存,这是相当具有挑战性的。
大多数 CPU 设计通过并行执行多个指令的部分来增加吞吐量(见图 1)。当流水线中的一个指令的结果被管道中的后续指令需要时,CPU 可能需要暂停前进进度或暂停,直到该结果准备好。
RAM 不是以单个字节读取的,而是以称为缓存行的块读取的。如果一起使用的变量可以放置在同一个缓存行上,它们将被一次性读取和写入。这通常提供了巨大的加速,但正如我们将在第 12 节中看到的,当一行必须在核心之间共享时,可能会伤害到我们。
这在进行配置文件引导优化时特别常见。
图 3:现代多核处理器的常见内存层次结构
所有这些复杂性意味着在多线程程序中,特别是在多核 CPU 上,没有一致的“现在”概念。在线程之间创建某种顺序是硬件、编译器、编程语言和你的应用程序的团队努力。让我们探索我们能做什么,以及我们需要什么工具。
维护秩序¶
创建多线程程序中的顺序需要在每个 CPU 架构上采取不同的方法。多年来,像 C 和 C++ 这样的系统语言没有并发的概念,迫使开发人员使用汇编或编译器扩展。这最终在 2011 年得到了解决,当时两种语言的 ISO 标准都添加了同步工具。只要你正确使用它们,编译器就会防止任何重排序——无论是由它自己的优化器还是由 CPU 引起的——导致数据竞争。
让我们再次尝试我们之前的例子。为了它的工作,"就绪"标志需要使用原子类型。
int v = 0; std::atomic_bool v_ready(false);
void threadA() { v = 42; v_ready = true; }
void threadB() {
while (!v_ready) { /* 等待 */ }
const int my_v = v; // 用 my_v 做一些事情...
}
C 和 C++ 标准库分别在
非正式地说,我们可以将原子变量视为线程的会合点。通过使 v_ready 原子化,现在在线程 A 中保证 v=42 发生在 v_ready=true 之前,就像在线程 B 中 my_v=v 必须在读取 v_ready 之后发生一样。正式地说,原子类型建立了一个单一的总修改顺序,其中,“[…]任何执行的结果都与如果读写发生在某个顺序中,并且每个单独处理器的操作按照其程序指定的顺序出现。”这个模型由 Leslie Lamport 在 1979 年定义,称为顺序一致性。
原子性¶
但顺序只是线程间通信的重要成分之一。另一个是原子类型所命名的原子性。如果某物是原子的,那么它就不能被分成更小的部分。如果线程不使用原子读写来共享数据,我们仍然会有麻烦。考虑一个程序,其中两个线程。一个处理文件列表,每次完成一个就递增计数器。另一个处理用户界面,定期读取计数器以更新进度条。如果计数器是一个 64 位整数,我们不能在 32 位机器上原子地访问它,因为我们需要两个加载或存储来读取或写入整个值。如果我们特别不幸,第一个线程可能在写计数器时中途,第二个线程读取它,接收到垃圾值。这些不幸的场合称为撕裂读和写。然而,如果对计数器的读写是原子的,我们的问题就消失了。我们可以看到,与建立正确顺序的困难相比,原子性相当直接:只要确保任何用于线程同步的变量不大于 CPU 字大小。
任意大小的“原子”类型¶
除了 atomic_int 和 friends,C++ 提供了模板 std::atomic 来定义任意原子类型。C 语言缺乏类似的语言特性,但想要提供相同的功能,增加了 _Atomic 关键字。如果 T 大于机器的字大小,编译器和语言运行时自动将锁围绕变量的读写。如果你想确保这不是发生的,你可以检查:
ISO C11 标准几乎逐字地从 C++11 标准中提升了其并发设施。这里你看到的一切在两种语言中应该是相同的,除了 C++ 中更干净的语法。†……这通常是大多数时间,因为我们通常使用原子操作来避免锁。
在大多数情况下,* 这些信息在编译时是已知的。因此,C++17 添加了 is_always_lock_free:
读写修改¶
加载和存储都很好,但有时我们需要读取一个值,修改它,并作为单个原子步骤写回。有一些常见的读写修改(rmw)操作。在 C++ 中,它们作为 std::atomic 的成员函数表示。在 C 中,它们是自由函数。
交换¶
最简单的原子 rmw 操作是交换:读取当前值并用新值替换。为了看看这可能在哪里有用,让我们调整我们在 §3 中的例子:UI 可能想要显示每秒钟处理的文件总数,而不是显示处理的文件总数。我们可以通过让 UI 线程每秒读取计数器然后将它归零来实现这一点。但如果读取和归零是分开的步骤,我们可能会遇到以下竞争条件:
- UI 线程读取计数器。
- 在 UI 线程有机会将其归零之前,工作线程再次递增它。
- UI线程现在将计数器归零,先前的递增丢失了。如果 UI 线程原子地用零交换当前值,竞争就消失了。
测试并设置¶
测试并设置在布尔值上工作:我们读取它,将其设置为 true,并提供它之前持有的值。C 和 C++ 为此目的提供了一种专用类型,称为 atomic_flag。我们可以用它来构建一个简单的自旋锁:
std::atomic_flag af;
void lock() {
while (af.test_and_set()) { /* 等待 */ }
}
void unlock() {
af.clear();
}
取值并...¶
我们还可以读取一个值,对其执行一些简单的操作(加法、减法、位与、或、异或),并返回其先前的值——全部作为一个原子操作。你可能已经注意到在交换示例中,工作线程的递增也必须是原子的,否则我们可能会遇到一个竞争条件,其中:
- 工作线程加载当前计数器值并增加一。
- 在那个线程可以存储值之前,UI 线程将计数器归零。
- 工作线程现在执行其存储,就好像计数器从未被清零过一样。
比较并交换¶
最后,我们有比较并交换(cas),有时称为比较并交换。它允许我们在先前值与某个期望值匹配的情况下有条件地交换值。在 C 和 C++ 中,cas 看起来如下,如果它被原子执行:
template <typename T> bool atomic<T>::compare_exchange_strong( T& expected, T desired) {
if (*this == expected) {
*this = desired; return true; }
else { expected = *this; return false; }
}
你可能对 _strong 后缀感到困惑。有没有“弱” cas?是的,但先别急——我们将在 §8.1 中讨论。假设我们有一个可能想要取消的长时间运行的任务。我们将给它三个状态:空闲、运行中和已取消,并编写一个循环,当它被取消时退出。
语言标准允许原子类型有时是无锁的。这可能对于不保证未对齐读写原子性的架构是必要的。
enum class TaskState : int8_t {
Idle, Running, Cancelled
};
std::atomic<TaskState> ts;
void taskLoop()
{
ts = TaskState::Running;
while (ts == TaskState::Running) {
// Do good work.
}
}
如果我们想在任务正在运行时取消它,但在以下情况下什么都不做 它是空闲的,我们可以这样:
bool cancel()
{
auto expected = TaskState::Running;
return ts.compare_exchange_strong(
expected, TaskState::Cancelled);
}
原子操作¶
作为构建块 原子加载、存储和 rmw 操作是每一个并发工具的构建块。将这些工具分成两类是有用的:阻塞和无锁。阻塞同步方法通常更易于理解,但它们可以使线程暂停任意长的时间。例如,考虑一个互斥锁,它强制线程轮流访问共享数据。如果某个线程锁定了互斥锁,另一个线程尝试做同样的事情,第二个线程必须等待——或者阻塞——直到第一个线程释放锁,无论那可能需要多长时间。阻塞机制也容易受到死锁和活锁的影响——由于线程相互等待,整个系统“卡住”的错误。相比之下,无锁同步方法确保程序始终在向前发展。这些是非阻塞的,因为没有一个线程可以使另一个线程无限期地等待。考虑一个流式传输音频的程序,或者一个嵌入式系统,当新数据到达时,传感器触发中断服务例程(isr)。我们希望在这些情况下使用无锁算法和数据结构,因为阻塞可能会破坏它们。(在第一种情况下,如果声音数据没有以消费的比特率提供,用户的音频将开始卡顿。在第二种情况下,如果 isr 没有尽可能快地完成,可能会错过后续的传感器输入。)
重要的是指出,无锁算法并不在某种程度上比阻塞算法更好或更快——它们只是为不同工作设计的不同类型的工具。我们还应该注意,算法仅仅因为只使用原子操作而自动无锁——我们原始的自旋锁从 §5.2 仍然是一个阻塞算法,即使它没有使用任何操作系统提供的系统调用将阻塞线程置于睡眠状态。
当然,有些情况下阻塞或无锁方法都可以使用。† 当性能成为关注点时,进行性能分析!性能取决于许多因素,从参与的线程数量到你的 CPU 的具体情况。并且一如既往,考虑你在复杂性和性能之间做出的权衡——并发是一种危险的艺术。
在弱有序硬件上的顺序一致性¶
不同的硬件架构提供不同的顺序保证或内存模型。例如,x64 相对强有序,可以在大多数情况下被信任为保持加载和存储的某种系统范围顺序。像 arm 这样的其他架构是弱有序的,所以你不能假设除非 CPU 被给予特殊指令——称为内存屏障——否则加载和存储会按程序顺序执行。了解原子操作在弱有序系统中的工作原理是有帮助的,这既可以帮助我们了解硬件中发生了什么,也可以看到 C 和 C++ 并发模型为何如此设计。让我们来检查一下 arm,因为它既受欢迎又简单。考虑最简单的原子操作:加载和存储。给定某个 atomic_int foo,
变成:
变为
我们加载我们的原子变量的地址到一个临时寄存器(r3),在内存屏障(dmb)之间夹住我们的加载或存储,然后返回。屏障为我们提供了顺序一致性——第一个确保之前的读写不能放在我们操作之后,第二个确保之后的读写不能放在它之前。
使用 LL/SC 指令实现原子读写修改操作¶
像许多其他 RISC* 架构一样,arm 缺少专用的 rmw 指令。由于处理器可以随时切换到另一个线程,我们不能从普通加载和存储构建 rmw 操作。相反,我们需要特殊指令:加载链接和存储条件(ll/sc)。两者协同工作:加载链接从地址读取一个值——像任何其他加载一样——但也指示处理器监视那个地址。存储条件仅在自相应的加载链接以来没有对该地址进行其他存储时才写入给定的值。让我们看看它们在原子获取和添加中的操作。在 arm 上,
编译为:
incFoo:
ldr r3, <&foo>
dmb
loop:
ldrex r2, [r3] // LL foo
add r2, r2, #1 // 递增
strex r1, r2, [r3] // SC
cmp r1, #0 // 检查 SC 结果。
bne loop // 如果 SC 失败则循环。
dmb
bx lr
偶发的 LL/SC 失败¶
正如你所能想象的,要跟踪机器上每个字节的加载链接地址,对 CPU 硬件来说成本太高了。为了降低这个成本,许多处理器以更粗糙的粒度监视它们,例如缓存行。这意味着如果存储条件前面有任何地址的写入,而不仅仅是特定的加载链接地址,sc 就可能失败。
这对于比较和交换尤为麻烦,也是比较交换弱存在的原因。考虑一个以原子方式乘以一个值的函数,即使没有读取-乘法-写入的原子指令在任何常见的架构中。
void atomicMultiply(int by) {
int expected = foo; // 我们应该使用哪个 CAS?
while (!foo.compare_exchange_?( expected, expected * by)) { // 空循环。
// (失败时,expected 用 foo 的最新值更新。)
}
}
许多无锁算法使用像这样的 cas 循环来原子地更新一个变量,当计算其新值不是原子的时候。它们:
- 读取变量。
- 对其值执行一些(非原子)操作。
- 将新值与先前的值进行 cas。
- 如果 cas 失败,另一个线程先于我们到达,所以重试。
如果我们为此类型的算法使用 compare_exchange_strong,则编译器必须发出嵌套循环:一个内部循环来保护我们免受偶发 sc 失败的影响,一个外部循环,重复执行我们的操作,直到没有其他线程中断我们。但与 _strong 版本不同,弱 cas 允许像实现它的 ll/sc 机制一样偶发失败。所以,使用 compare_exchange_weak,编译器可以自由地生成一个单一循环,因为我们不关心由于偶发 sc 失败和由于另一个线程修改我们的变量引起的重试之间的区别。
我们总是需要顺序一致的操作吗?¶
我们到目前为止的所有示例都是顺序一致的,以防止重排序破坏我们的代码。我们还看到像 arm 这样的弱有序架构如何使用内存屏障来创建顺序一致性。但正如你可能预料的那样,这些屏障可能对性能产生有显著影响。毕竟,它们抑制了编译器和硬件本可以进行的优化。如果我们能避免一些这种减速呢?考虑一个简单的例子,像 §5.2 中的自旋锁。在 lock() 和 unlock() 调用之间,我们有一个临界区,我们可以安全地修改受锁保护的共享状态。
deepThought.calculate(); // 非共享
lock(); // 加锁;开始临界区
sharedState.subject = "Life, the universe and everything";
sharedState.answer = 42;
unlock(); // 解锁;结束临界区
demolishEarth(vogons); // 非共享
关键是要确保对共享内存的读写不会移出临界区。但反过来就不一定了!编译器和硬件可以在不引起任何麻烦的情况下将尽可能多的代码移入临界区。如果我们能以某种方式让这变得更快,我们不会有任何问题:
lock(); // 加锁;开始临界区
deepThought.calculate(); // 非共享
sharedState.subject = "Life, the universe and everything";
sharedState.answer = 42;
demolishEarth(vogons); // 非共享
unlock(); // 解锁;结束临界区
那么,我们如何告诉编译器这些信息呢?
内存排序¶
默认情况下,所有原子操作(包括加载、存储和各种类型的 rmw)都是顺序一致的。但这只是我们可以给它们的几种排序之一。我们将检查每一种,但完整的列表以及 C 和 C++ API 使用的枚举如下:
- 顺序一致 (memory_order_seq_cst)
- 获得 (memory_order_acquire)
- 释放 (memory_order_release)
- 放松 (memory_order_relaxed)
- 获得-释放 (memory_order_acq_rel)
- 消费 (memory_order_consume)
为了选择一个排序,你作为一个可选参数提供,我们到目前为止狡猾地没有提到:
void lock() {
while (af.test_and_set( memory_order_acquire)) { /* 等待 */ }
}
void unlock() {
af.clear(memory_order_release);
}
非顺序一致的加载和存储也使用 std::atomic<> 的成员函数:
比较并交换操作有点奇怪,因为它们有两种排序:一种是当 cas 成功时,另一种是当它失败时:
while (!foo.compare_exchange_weak( expected, expected * by, memory_order_seq_cst, // 成功时
memory_order_relaxed)) // 失败时 { /* 空循环 */ }
有了这些语法,让我们看看这些排序是什么以及如何使用它们。事实证明,我们到目前为止看到的所有示例实际上并不需要顺序一致的操作。
获得和释放¶
我们在 §9 的锁示例中刚刚看到了获得和释放的作用。你可以将它们想象成“单向”屏障:获得允许其他读写操作过去,但只有在 before → after 的方向上。释放则以相反的方式工作,让事情在 after → before 的方向上移动。在 arm 和其他弱有序架构上,这允许我们在每个操作中减少一个内存屏障,使得
int acquireFoo() {
return foo.load(memory_order_acquire);
}
void releaseFoo(int i) {
foo.store(i, memory_order_release);
}
变为:
acquireFoo:
ldr r3, <&foo>
ldr r0, [r3, #0]
dmb
bx lr
releaseFoo:
ldr r3, <&foo>
dmb
str r0, [r3, #0]
bx lr
它们一起提供写者 → 读者同步:如果线程 W 用释放语义存储一个值,并且线程 R 用获得语义加载那个值,那么 W 在其存储释放之前所做的所有写入对 R 在其加载获得之后都是可观察的。如果这听起来很熟悉,这正是我们在 §1 和 §2 中尝试实现的:
int v; std::atomic_bool v_ready(false);
void threadA() {
v = 42;
v_ready.store(true, memory_order_release);
}
void threadB() {
while (!v_ready.load(memory_order_acquire)) {
// 等待
}
assert(v == 42); // 必须为真
}
放松¶
放松的原子操作用于当变量在线程之间共享,但不需要特定顺序时。虽然这看起来可能很少见,但实际上它是令人惊讶的常见。
回想一下我们在 §3 和 §5 中的例子,其中一个工作线程正在递增一个计数器,然后由 UI 线程读取。
那个计数器可以用 fetch_add(1, memory_order_relaxed) 来递增,因为我们所需要的只是原子性——计数器没有同步任何东西。放松的读写也用于在线程之间共享标志。考虑某个线程循环直到被告知退出:
atomic_bool stop(false);
void worker() {
while (!stop.load(memory_order_relaxed)) {
// 做好工作
}
}
int main() {
launchWorker(); // 等待一些...
stop = true; // 顺序一致 连接Worker();
}
我们不在乎循环的内容是否围绕加载重新排列。只要 stop 只用于告诉工作线程退出,而不是用于“宣布”任何新数据,就什么坏事都不会发生。最后,放松的加载通常与 cas 循环一起使用。回到我们的无锁乘法:
void atomicMultiply(int by) {
int expected = foo.load(memory_order_relaxed);
while (!foo.compare_exchange_weak( expected, expected * by, memory_order_release, memory_order_relaxed)) {
// 空循环
}
}
所有的加载都可以是放松的——在我们成功修改我们的值之前,我们不需要强制任何顺序。expected 的初始加载甚至不是严格必要的。如果 foo 在 cas 之前没有被其他线程修改,它只会节省我们一次循环迭代。
获得-释放¶
memory_order_acq_rel 用于需要同时加载-获得和存储-释放一个值的原子 rmw 操作。一个典型的例子涉及线程安全的引用计数,如 C++ 的 shared_ptr 中:
atomic_int refCount;
void inc() {
refCount.fetch_add(1, memory_order_relaxed);
}
void dec() {
if (refCount.fetch_sub(1, memory_order_acq_rel) == 1) {
// 没有更多的引用,删除数据。
}
}
增加引用计数时顺序不重要,因为不会产生任何操作结果。但是,当我们减少计数时,我们必须确保:
- 所有对引用对象的访问都发生在计数达到零之前。
- 删除发生在引用计数达到零之后。
好奇的读者可能想知道获得-释放和顺序一致操作之间的区别。引用 ISO C++ 并发研究小组主席 Hans Boehm 的话:
获得-释放和 seq_cst 之间的区别通常是操作是否需要参与到顺序一致操作的全局顺序中。
换句话说,获得-释放提供了与被加载-获得和存储-释放的变量相关的顺序,而顺序一致的操作提供了整个程序的某种全局顺序。如果这种区别仍然看起来模糊,你并不孤单。Boehm 接着说:
这具有微妙和不直观的效果。当前标准中的 [屏障] 可能是我们语言中最具专家性质的结构。
消费¶
最后但同样重要的是,我们有 memory_order_consume。考虑一种场景,数据很少改变,但经常被许多线程读取。也许我们正在编写一个内核,我们正在跟踪插入到机器中的外围设备。这个信息很少改变——只有在有人插入或拔出东西时才会改变——所以优化读取操作是有意义的。根据我们目前所知道的,我们最好的做法是:
std::atomic<PeripheralData*> peripherals;
// 写者:PeripheralData* p = kAllocate(sizeof(*p)); populateWithNewDeviceData(p); peripherals.store(p, memory_order_release);
// 读者:PeripheralData* p = peripherals.load(memory_order_acquire);
if (p != nullptr) { doSomethingWith(p->keyboards); }
为了进一步优化读者,如果加载可以避免在弱有序系统上进行内存屏障就好了。事实证明,它们通常可以。由于我们检查的数据(p->keyboards)依赖于 p 的值,即使是弱有序的平台也不能将初始加载(p = peripherals)重排到其使用之后(p->keyboards)。只要我们说服编译器不要进行类似的推测,我们就清楚了。这就是 memory_order_consume 的用途。将读者改为:
PeripheralData* p = peripherals.load(memory_order_consume);
if (p != nullptr) { doSomethingWith(p->keyboards); }
并且一个 arm 编译器可以发出:
ldr r3, &peripherals
ldr r3, [r3] // 看哪,没有屏障!
cbz r3, was_null // 检查空值
ldr r0, [r3, #4] // 加载 p->keyboards
b doSomethingWith(Keyboards*)
was_null: ...
遗憾的是,这里的强调是“可以”。弄清楚什么构成了表达式之间的“依赖性”并不像人们希望的那样简单,所以所有编译器当前都将消费操作转换为获得。
龙出没¶
非顺序一致的排序有很多微妙之处,一点小错误就可能导致难以捉摸的海森堡bug,这些bug只在某些时候、某些平台上出现。在求助于它们之前,扪心自问:
- 我是否在使用众所周知并被理解的模式(如上文所示)?
- 这些操作是否在一个紧密循环中?
- 这里的每一微秒都至关重要吗? 如果这些问题的答案不是多个“是”的,坚持使用顺序一致的操作。否则,请确保给你的代码额外的审查和测试。
硬件融合¶
那些熟悉 ARM 的人可能已经注意到,这里展示的所有汇编代码都是针对该架构的第七版。令人兴奋的是,第八代提供了对无锁代码的巨大改进。由于大多数编程语言都已收敛到我们所探讨的内存模型,ARMv8 处理器提供了专用的加载-获得和存储-释放指令:lda 和 stl。希望未来的 CPU 架构将跟进。
缓存效应和伪共享¶
如果这些还不够让你头疼,现代硬件又给我们带来了一个难题。回想一下,内存是以称为缓存行的块在主 RAM 和 CPU 之间传输的。这些行也是核心和它们各自的缓存之间传输的最小单位——如果一个核心写入一个值,另一个核心读取它,那么包含该值的整个行必须从第一个核心的缓存传输到第二个核心的缓存,以保持它们对内存的“视图”一致性。这可能会对性能产生惊人的影响。考虑一个读写锁,它通过确保共享数据有一个写入者或任意数量的读取者,但永远不会同时存在来避免竞争。从本质上讲,它类似于以下内容:
写入者必须阻塞,直到 readers 达到零,但读取者可以在 hasWriter 为 false 时使用原子 rmw 操作获取锁。天真地看,这似乎为我们比排他锁(例如,互斥锁、自旋锁等)提供了巨大的性能提升,特别是在我们比写入更频繁地读取共享数据的情况下,但这没有考虑到缓存效应。如果有多个读取者——每个都在不同的核心上运行——同时获取锁,它的缓存行将在这些核心的缓存之间“来回传递”。除非关键部分非常大,否则解决这种争用可能比关键部分本身花费的时间更长,即使算法没有阻塞。当这种情况发生在碰巧被放置在同一个缓存行上的不相关变量之间时,这种减速更加隐蔽。在设计并发数据结构或算法时,必须考虑到这种伪共享。避免它的一种方法是用未共享数据的缓存行填充原子变量,但这显然是一个巨大的空间-时间权衡。
如果并发是问题,volatile 并不是答案¶
在我们继续之前,我们应该消除围绕 volatile 关键字的一个常见误解。也许是因为它在旧编译器和硬件中的工作方式,或者是由于它在 Java 和 C# 等语言中的不同含义,一些人认为该关键字对于构建并发工具很有用。除了一个特定情况(见 §14),这是不正确的。volatile 的目的是告知编译器一个值可能被我们正在执行的程序之外的某些东西改变。这对于内存映射 I/O(MMIO)很有用,硬件将对某些地址的读写转换为连接到 CPU 的设备的指令。(这是大多数机器最终与外部世界交互的方式。)volatile 意味着两个保证:
编译器不会省略那些看起来“不必要”的加载和存储。例如,如果我有一个函数:
编译器通常会将其优化为:
*t = 2 通常被认为是一个无效存储,什么也不做。但是,如果 t 指向某个 MMIO 寄存器,就不应该这样假设——每次写入都可能对它交互的硬件产生一些影响。
出于类似的原因,编译器不会重新排序 volatile 读写操作相对于其他 volatile 操作的顺序。
这些规则并没有为我们提供线程间安全通信所需的原子性或顺序。注意,第二个保证只防止 volatile 操作相对于彼此重新排序——编译器仍然可以自由地围绕它们重新排序所有其他“正常”的加载和存储。即使我们把这个问题放在一边,volatile 也没有在弱有序硬件上发出内存屏障。这个关键字只有在你的编译器和硬件都不重新排序的情况下才作为同步机制起作用。不要依赖于此。
原子融合¶
最后,应该意识到,尽管原子操作确实防止了某些优化,但它们并没有对所有优化免疫。优化器可以做相当平凡的事情,比如用 foo = 0 替换 foo.fetch_and(0),它也可以产生令人惊讶的结果。考虑:
由于 relaxed 加载不提供任何顺序保证,编译器可以随意展开循环,比如:
while (tmp = foo.load(memory_order_relaxed)) {
doSomething(tmp);
doSomething(tmp);
doSomething(tmp);
doSomething(tmp);
}
如果像这样“融合”读取或写入是不可接受的,我们必须用 volatile 强制转换或像 asm volatile("") 这样的咒语来防止它。* Linux 内核为此提供了 READ_ONCE() 和 WRITE_ONCE() 宏。
要点¶
我们在这里只是浅尝辄止,但希望你现在知道了:
- 为什么编译器和 CPU 硬件会重新排序加载和存储。
- 为什么我们需要特殊的工具来防止这些重新排序以便在线程之间通信。
- 如何在我们的程序中保证顺序一致性。
- 原子读写修改操作。
- 在弱有序硬件上如何实现原子操作,以及这可能对语言级 API 产生的影响。
- 如何使用非顺序一致的内存排序来仔细优化无锁代码。
- 伪共享如何影响并发内存访问的性能。
- volatile 是线程间通信的不适当工具。
- 如何防止编译器以不理想的方式融合原子操作。
要了解更多信息,请参见下面的其他资源,或检查无锁数据结构和算法,例如单生产者/单消费者 (SP/SC) 队列或读写更新 (RCU)。
祝你好运!
其他资源¶
- Fedor Pikus 关于 C++ 原子性的演讲,从基础到高级。他们到底做了什么? 这是一个关于这个话题的一小时演讲。
- Herb Sutter 的 "atomic<>:C++11 内存模型和现代硬件",这是一个三小时的深入讲解。图 2 和图 3 的来源。
- Ulrich Drepper 的论文 "Futexes 是棘手的",讲述了如何在 Linux 中使用原子操作和系统调用构建互斥锁和其他同步原语。
- Paul E. McKenney 的 "并行编程难吗,如果是的话,你能做些什么?",一本难以置信的全面书籍,涵盖了并行数据结构和算法、事务内存、缓存一致性协议、CPU 架构细节等。
- "内存屏障:软件黑客的硬件视角",McKenney 的一个较旧但较短的文章,解释了 Linux 内核在各种架构上如何实现内存屏障。
- Preshing 关于编程的博客,有许多关于无锁并发的优秀文章。
- "没有理智的编译器会优化原子性",讨论了当前优化器如何处理原子操作。可用作书面材料,n4455,也可以作为 CppCon 演讲。
- cppreference.com,一个关于 C 和 C++ 内存模型和原子 API 的极好参考资料。
- Matt Godbolt 的编译器探索器,一个在线工具,提供使用您选择的编译器和标志的生活、彩色汇编。非常适合检查不同架构上各种原子操作的编译器发射内容。
贡献¶
欢迎贡献!源代码和历史记录可在 Gitlab 和 Github 上获得。本文是用 LaTeX 准备的——如果你不熟悉它,可以通过电子邮件、开启问题等方式联系作者(而不是拉取请求)。
本文根据创作共用署名-相同方式共享 4.0 国际许可发布。法律条款可以通过 https://creativecommons.org/licenses/by-sa/4.0/ 获得,简而言之,你可以自由复制、重新分发、翻译或以其他方式转换本文,只要你给予适当的信用,指出是否进行了更改,并在相同的许可下发布你的版本。
尾声¶
本指南使用 Matthew Butterick 的 Equity 字体,由 LuaLATEX 排版,代码使用 Matthias Tellen 的 mononoki 字体。标题设置为 Christian Schwartz 恢复的 Helvetica,即 Neue Haas Grotesk。
Ended: 每个系统程序员都应该了解的并发知识
Ended: what you should know
Ended: 系统设计
Jupyter/视频/PPT ↵
Jupyter ↵
!pip install pandas matplotlib
WARNING: pip is being invoked by an old script wrapper. This will fail in a future version of pip. Please see https://github.com/pypa/pip/issues/5599 for advice on fixing the underlying issue. To avoid this problem you can invoke Python with '-m pip' instead of running pip directly. Defaulting to user installation because normal site-packages is not writeable Requirement already satisfied: pandas in /home/deploy/.local/lib/python3.6/site-packages (1.0.0) Requirement already satisfied: matplotlib in /home/deploy/.local/lib/python3.6/site-packages (3.1.3) Requirement already satisfied: python-dateutil>=2.6.1 in /home/deploy/.local/lib/python3.6/site-packages (from pandas) (2.8.1) Requirement already satisfied: pytz>=2017.2 in /home/deploy/.local/lib/python3.6/site-packages (from pandas) (2019.3) Requirement already satisfied: numpy>=1.13.3 in /home/deploy/.local/lib/python3.6/site-packages (from pandas) (1.18.1) Requirement already satisfied: kiwisolver>=1.0.1 in /home/deploy/.local/lib/python3.6/site-packages (from matplotlib) (1.1.0) Requirement already satisfied: cycler>=0.10 in /home/deploy/.local/lib/python3.6/site-packages (from matplotlib) (0.10.0) Requirement already satisfied: pyparsing!=2.0.4,!=2.1.2,!=2.1.6,>=2.0.1 in /home/deploy/.local/lib/python3.6/site-packages (from matplotlib) (2.4.6) Requirement already satisfied: six>=1.5 in /home/deploy/.local/lib/python3.6/site-packages (from python-dateutil>=2.6.1->pandas) (1.14.0) Requirement already satisfied: setuptools in /home/deploy/.local/lib/python3.6/site-packages (from kiwisolver>=1.0.1->matplotlib) (45.1.0)
import requests
import pandas as pd
# 统计go框架fork次数信息
frameworks = {
"Gin":"gin-gonic/gin",
"Beego": "astaxie/beego",
"Iris": "kataras/iris",
"Revel": "revel/revel",
"Echo": "labstack/echo",
"Buffalo": "gobuffalo/buffalo"
}
stats = {}
for name in frameworks.keys():
url = "https://api.github.com/repos/" + frameworks[name]
stats[name] = requests.get(url=url).json() # 获取仓库统计信息
indexs = []
forks = []
stars = []
watchs = []
openIssues = []
for name in stats:
indexs += [name]
forks += [stats[name]['forks_count']] # fork次数
stars += [stats[name]['watchers_count']] # star次数
watchs += [stats[name]['subscribers_count']] # watch次数
openIssues += [stats[name]['open_issues_count']] # open_issue次数
df = pd.DataFrame({
'forks':forks,
'stars':stars,
'watchs':watchs,
'openIssues': openIssues
}, index = indexs)
df
| forks | stars | watchs | openIssues | |
|---|---|---|---|---|
| Gin | 4074 | 35455 | 1212 | 242 |
| Beego | 4688 | 23243 | 1268 | 813 |
| Iris | 1942 | 17507 | 683 | 5 |
| Revel | 1357 | 11575 | 558 | 87 |
| Echo | 1508 | 16500 | 551 | 46 |
| Buffalo | 430 | 5372 | 171 | 70 |
df.plot(kind='bar', figsize=(15, 8))
<matplotlib.axes._subplots.AxesSubplot at 0x7f6786d59240>
Pandas完全指南
Pandas 是一个Python语言实现的,开源,易于使用的数据架构以及数据分析工具。在Pandas中主要有两种数据类型,可以简单的理解为:
- Series:一维数组(列表)
- DateFrame:二维数组(矩阵)
在线实验:Pandas完全指南.ipynb
学习资料:
导入pandas¶
# 安装pandas,matplotlib(绘图用) 包
!pip install pandas matplotlib
# 导入包
import pandas as pd
import numpy as np
from IPython.display import Image
s = pd.Series([1, 3, 6, np.nan, 23, 3]) # type(s) === 'pandas.core.series.Series'
dates = pd.date_range('20200101', periods=6)
根据列表(Series)创建矩阵¶
df = pd.DataFrame(np.random.randn(6, 4), index=dates, columns=['a', 'b', 'c', 'd'])
df
| a | b | c | d | |
|---|---|---|---|---|
| 2020-01-01 | 2.078888 | -0.959554 | -0.367265 | 1.108948 |
| 2020-01-02 | -0.412362 | 0.232540 | -1.903134 | -1.831848 |
| 2020-01-03 | -1.898721 | 0.919976 | 0.485630 | 0.758721 |
| 2020-01-04 | 0.486961 | 0.378323 | 0.186727 | 0.671816 |
| 2020-01-05 | 0.702523 | -0.556798 | 0.635000 | -0.118564 |
| 2020-01-06 | 0.654506 | -0.000727 | 0.417828 | -0.611751 |
df2 = pd.DataFrame({
'a':pd.Series([1, 2, 3, 4]),
'b':pd.Timestamp('20180708'),
'c':pd.Categorical(['cate1', 'cate2', 'cate3', 'cate4'])
})
df2
| a | b | c | |
|---|---|---|---|
| 0 | 1 | 2018-07-08 | cate1 |
| 1 | 2 | 2018-07-08 | cate2 |
| 2 | 3 | 2018-07-08 | cate3 |
| 3 | 4 | 2018-07-08 | cate4 |
data = {'name': ['Jason', 'Molly', 'Tina', 'Jake', 'Amy', 'Jack', 'Tim'],
'age': [20, 32, 36, 24, 23, 18, 27],
'gender': np.random.choice(['M','F'],size=7),
'score': [25, 94, 57, 62, 70, 88, 67],
'country': np.random.choice(['US','CN'],size=7),
}
df3 = pd.DataFrame(data, columns = ['name', 'age', 'gender', 'score', 'country'])
df3
| name | age | gender | score | country | |
|---|---|---|---|---|---|
| 0 | Jason | 20 | F | 25 | US |
| 1 | Molly | 32 | F | 94 | CN |
| 2 | Tina | 36 | F | 57 | CN |
| 3 | Jake | 24 | F | 62 | US |
| 4 | Amy | 23 | F | 70 | CN |
| 5 | Jack | 18 | F | 88 | US |
| 6 | Tim | 27 | F | 67 | CN |
df.shape
(6, 4)
df.index
DatetimeIndex(['2020-01-01', '2020-01-02', '2020-01-03', '2020-01-04',
'2020-01-05', '2020-01-06'],
dtype='datetime64[ns]', freq='D')
df.columns
Index(['a', 'b', 'c', 'd'], dtype='object')
df.values
array([[ 2.07888761e+00, -9.59553787e-01, -3.67264810e-01,
1.10894771e+00],
[-4.12361501e-01, 2.32539690e-01, -1.90313388e+00,
-1.83184759e+00],
[-1.89872061e+00, 9.19975617e-01, 4.85630402e-01,
7.58720982e-01],
[ 4.86960560e-01, 3.78322949e-01, 1.86726767e-01,
6.71815555e-01],
[ 7.02523492e-01, -5.56797752e-01, 6.35000384e-01,
-1.18564302e-01],
[ 6.54506255e-01, -7.26685067e-04, 4.17828341e-01,
-6.11751157e-01]])
df.info()
<class 'pandas.core.frame.DataFrame'> DatetimeIndex: 6 entries, 2020-01-01 to 2020-01-06 Freq: D Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 a 6 non-null float64 1 b 6 non-null float64 2 c 6 non-null float64 3 d 6 non-null float64 dtypes: float64(4) memory usage: 240.0 bytes
df.describe()
| a | b | c | d | |
|---|---|---|---|---|
| count | 6.000000 | 6.000000 | 6.000000 | 6.000000 |
| mean | 0.268633 | 0.002293 | -0.090869 | -0.003780 |
| std | 1.328384 | 0.674432 | 0.954544 | 1.095503 |
| min | -1.898721 | -0.959554 | -1.903134 | -1.831848 |
| 25% | -0.187531 | -0.417780 | -0.228767 | -0.488454 |
| 50% | 0.570733 | 0.115907 | 0.302278 | 0.276626 |
| 75% | 0.690519 | 0.341877 | 0.468680 | 0.736995 |
| max | 2.078888 | 0.919976 | 0.635000 | 1.108948 |
### 更改索引
df.index = pd.date_range('2020/06/01', periods=df.shape[0])
df
| a | b | c | d | |
|---|---|---|---|---|
| 2020-06-01 | 2.078888 | -0.959554 | -0.367265 | 1.108948 |
| 2020-06-02 | -0.412362 | 0.232540 | -1.903134 | -1.831848 |
| 2020-06-03 | -1.898721 | 0.919976 | 0.485630 | 0.758721 |
| 2020-06-04 | 0.486961 | 0.378323 | 0.186727 | 0.671816 |
| 2020-06-05 | 0.702523 | -0.556798 | 0.635000 | -0.118564 |
| 2020-06-06 | 0.654506 | -0.000727 | 0.417828 | -0.611751 |
top5 数据¶
df.head(1)
| a | b | c | d | |
|---|---|---|---|---|
| 2020-06-01 | 2.078888 | -0.959554 | -0.367265 | 1.108948 |
tail5 数据¶
df.tail(5)
| a | b | c | d | |
|---|---|---|---|---|
| 2020-06-02 | -0.412362 | 0.232540 | -1.903134 | -1.831848 |
| 2020-06-03 | -1.898721 | 0.919976 | 0.485630 | 0.758721 |
| 2020-06-04 | 0.486961 | 0.378323 | 0.186727 | 0.671816 |
| 2020-06-05 | 0.702523 | -0.556798 | 0.635000 | -0.118564 |
| 2020-06-06 | 0.654506 | -0.000727 | 0.417828 | -0.611751 |
df['a'].value_counts(dropna=False)
-1.898721 1 0.702523 1 2.078888 1 0.486961 1 0.654506 1 -0.412362 1 Name: a, dtype: int64
df.apply(pd.Series.value_counts)
| a | b | c | d | |
|---|---|---|---|---|
| -1.903134 | NaN | NaN | 1.0 | NaN |
| -1.898721 | 1.0 | NaN | NaN | NaN |
| -1.831848 | NaN | NaN | NaN | 1.0 |
| -0.959554 | NaN | 1.0 | NaN | NaN |
| -0.611751 | NaN | NaN | NaN | 1.0 |
| -0.556798 | NaN | 1.0 | NaN | NaN |
| -0.412362 | 1.0 | NaN | NaN | NaN |
| -0.367265 | NaN | NaN | 1.0 | NaN |
| -0.118564 | NaN | NaN | NaN | 1.0 |
| -0.000727 | NaN | 1.0 | NaN | NaN |
| 0.186727 | NaN | NaN | 1.0 | NaN |
| 0.232540 | NaN | 1.0 | NaN | NaN |
| 0.378323 | NaN | 1.0 | NaN | NaN |
| 0.417828 | NaN | NaN | 1.0 | NaN |
| 0.485630 | NaN | NaN | 1.0 | NaN |
| 0.486961 | 1.0 | NaN | NaN | NaN |
| 0.635000 | NaN | NaN | 1.0 | NaN |
| 0.654506 | 1.0 | NaN | NaN | NaN |
| 0.671816 | NaN | NaN | NaN | 1.0 |
| 0.702523 | 1.0 | NaN | NaN | NaN |
| 0.758721 | NaN | NaN | NaN | 1.0 |
| 0.919976 | NaN | 1.0 | NaN | NaN |
| 1.108948 | NaN | NaN | NaN | 1.0 |
| 2.078888 | 1.0 | NaN | NaN | NaN |
根据索引(index)排序¶
# sort_index(axis=, ascending=)
# axis:0-行排序,1-列排序; ascending:True-升序,False-降序
df.sort_index(axis=0, ascending=False)
| a | b | c | d | |
|---|---|---|---|---|
| 2020-06-06 | 0.654506 | -0.000727 | 0.417828 | -0.611751 |
| 2020-06-05 | 0.702523 | -0.556798 | 0.635000 | -0.118564 |
| 2020-06-04 | 0.486961 | 0.378323 | 0.186727 | 0.671816 |
| 2020-06-03 | -1.898721 | 0.919976 | 0.485630 | 0.758721 |
| 2020-06-02 | -0.412362 | 0.232540 | -1.903134 | -1.831848 |
| 2020-06-01 | 2.078888 | -0.959554 | -0.367265 | 1.108948 |
df.sort_index(axis=1, ascending=False)
| d | c | b | a | |
|---|---|---|---|---|
| 2020-06-01 | 1.108948 | -0.367265 | -0.959554 | 2.078888 |
| 2020-06-02 | -1.831848 | -1.903134 | 0.232540 | -0.412362 |
| 2020-06-03 | 0.758721 | 0.485630 | 0.919976 | -1.898721 |
| 2020-06-04 | 0.671816 | 0.186727 | 0.378323 | 0.486961 |
| 2020-06-05 | -0.118564 | 0.635000 | -0.556798 | 0.702523 |
| 2020-06-06 | -0.611751 | 0.417828 | -0.000727 | 0.654506 |
df.sort_values(by='a', ascending=False)
| a | b | c | d | |
|---|---|---|---|---|
| 2020-06-01 | 2.078888 | -0.959554 | -0.367265 | 1.108948 |
| 2020-06-05 | 0.702523 | -0.556798 | 0.635000 | -0.118564 |
| 2020-06-06 | 0.654506 | -0.000727 | 0.417828 | -0.611751 |
| 2020-06-04 | 0.486961 | 0.378323 | 0.186727 | 0.671816 |
| 2020-06-02 | -0.412362 | 0.232540 | -1.903134 | -1.831848 |
| 2020-06-03 | -1.898721 | 0.919976 | 0.485630 | 0.758721 |
df.sort_values(by=['a','b'], ascending=True)
| a | b | c | d | |
|---|---|---|---|---|
| 2020-06-03 | -1.898721 | 0.919976 | 0.485630 | 0.758721 |
| 2020-06-02 | -0.412362 | 0.232540 | -1.903134 | -1.831848 |
| 2020-06-04 | 0.486961 | 0.378323 | 0.186727 | 0.671816 |
| 2020-06-06 | 0.654506 | -0.000727 | 0.417828 | -0.611751 |
| 2020-06-05 | 0.702523 | -0.556798 | 0.635000 | -0.118564 |
| 2020-06-01 | 2.078888 | -0.959554 | -0.367265 | 1.108948 |
df['a'] # 等效于df.a
2020-06-01 2.078888 2020-06-02 -0.412362 2020-06-03 -1.898721 2020-06-04 0.486961 2020-06-05 0.702523 2020-06-06 0.654506 Freq: D, Name: a, dtype: float64
df['2020-06-01':'2020-06-02'] # 选取索引以2020-06-01开始,到2020-06-02结束的数据
| a | b | c | d | |
|---|---|---|---|---|
| 2020-06-01 | 2.078888 | -0.959554 | -0.367265 | 1.108948 |
| 2020-06-02 | -0.412362 | 0.232540 | -1.903134 | -1.831848 |
df[['c', 'b']]
| c | b | |
|---|---|---|
| 2020-06-01 | -0.367265 | -0.959554 |
| 2020-06-02 | -1.903134 | 0.232540 |
| 2020-06-03 | 0.485630 | 0.919976 |
| 2020-06-04 | 0.186727 | 0.378323 |
| 2020-06-05 | 0.635000 | -0.556798 |
| 2020-06-06 | 0.417828 | -0.000727 |
loc[行名选择, 列名选择],未指定行名或列名,或者指定为:则表示选择当前所有行,或列
df.loc['2020-06-01']
a 2.078888 b -0.959554 c -0.367265 d 1.108948 Name: 2020-06-01 00:00:00, dtype: float64
df.loc['2020-06-01', 'b']
-0.9595537865841992
df.loc[:, 'b'] # type(df.loc[:, 'b']) === 'pandas.core.series.Series',而type(df.loc[:, ['b']]) === ’pandas.core.frame.DataFrame‘
2020-06-01 -0.959554 2020-06-02 0.232540 2020-06-03 0.919976 2020-06-04 0.378323 2020-06-05 -0.556798 2020-06-06 -0.000727 Freq: D, Name: b, dtype: float64
df.loc[:, ['a', 'b']]
| a | b | |
|---|---|---|
| 2020-06-01 | 2.078888 | -0.959554 |
| 2020-06-02 | -0.412362 | 0.232540 |
| 2020-06-03 | -1.898721 | 0.919976 |
| 2020-06-04 | 0.486961 | 0.378323 |
| 2020-06-05 | 0.702523 | -0.556798 |
| 2020-06-06 | 0.654506 | -0.000727 |
df.iloc[0,0] # === df.loc['2020-06-01', 'a']
2.0788876064798893
df.iloc[0, :] # ==== df.loc['2020-06-01', :]
a 2.078888 b -0.959554 c -0.367265 d 1.108948 Name: 2020-06-01 00:00:00, dtype: float64
只有当布尔表达式为真时的数据才会被选择
df[df.a > 1]
| a | b | c | d | |
|---|---|---|---|---|
| 2020-06-01 | 2.078888 | -0.959554 | -0.367265 | 1.108948 |
df[(df['a'] > 1) & (df['d'] <0)]
| a | b | c | d |
|---|
df.loc['2020-06-01', 'a'] = np.nan
df.loc['2020-06-06', 'c'] = np.nan
df
| a | b | c | d | |
|---|---|---|---|---|
| 2020-06-01 | NaN | -0.959554 | -0.367265 | 1.108948 |
| 2020-06-02 | -0.412362 | 0.232540 | -1.903134 | -1.831848 |
| 2020-06-03 | -1.898721 | 0.919976 | 0.485630 | 0.758721 |
| 2020-06-04 | 0.486961 | 0.378323 | 0.186727 | 0.671816 |
| 2020-06-05 | 0.702523 | -0.556798 | 0.635000 | -0.118564 |
| 2020-06-06 | 0.654506 | -0.000727 | NaN | -0.611751 |
df['e'] = np.where((df['a'] > 1) & (df['d']<0), 1, 0)
df
| a | b | c | d | e | |
|---|---|---|---|---|---|
| 2020-06-01 | NaN | -0.959554 | -0.367265 | 1.108948 | 0 |
| 2020-06-02 | -0.412362 | 0.232540 | -1.903134 | -1.831848 | 0 |
| 2020-06-03 | -1.898721 | 0.919976 | 0.485630 | 0.758721 | 0 |
| 2020-06-04 | 0.486961 | 0.378323 | 0.186727 | 0.671816 | 0 |
| 2020-06-05 | 0.702523 | -0.556798 | 0.635000 | -0.118564 | 0 |
| 2020-06-06 | 0.654506 | -0.000727 | NaN | -0.611751 | 0 |
tmp = df.copy()
df.loc[:,'f'] = tmp.apply(lambda row: row['b']+ row['d'], axis=1)
df
| a | b | c | d | e | f | |
|---|---|---|---|---|---|---|
| 2020-06-01 | NaN | -0.959554 | -0.367265 | 1.108948 | 0 | 0.149394 |
| 2020-06-02 | -0.412362 | 0.232540 | -1.903134 | -1.831848 | 0 | -1.599308 |
| 2020-06-03 | -1.898721 | 0.919976 | 0.485630 | 0.758721 | 0 | 1.678697 |
| 2020-06-04 | 0.486961 | 0.378323 | 0.186727 | 0.671816 | 0 | 1.050139 |
| 2020-06-05 | 0.702523 | -0.556798 | 0.635000 | -0.118564 | 0 | -0.675362 |
| 2020-06-06 | 0.654506 | -0.000727 | NaN | -0.611751 | 0 | -0.612478 |
# 将所有等于1的值替换成20
df.replace(1,20)
| a | b | c | d | e | f | |
|---|---|---|---|---|---|---|
| 2020-06-01 | NaN | -0.959554 | -0.367265 | 1.108948 | 0 | 0.149394 |
| 2020-06-02 | -0.412362 | 0.232540 | -1.903134 | -1.831848 | 0 | -1.599308 |
| 2020-06-03 | -1.898721 | 0.919976 | 0.485630 | 0.758721 | 0 | 1.678697 |
| 2020-06-04 | 0.486961 | 0.378323 | 0.186727 | 0.671816 | 0 | 1.050139 |
| 2020-06-05 | 0.702523 | -0.556798 | 0.635000 | -0.118564 | 0 | -0.675362 |
| 2020-06-06 | 0.654506 | -0.000727 | NaN | -0.611751 | 0 | -0.612478 |
# 使用one替换1,three替换3
df.replace([1,3],['one','three'])
| a | b | c | d | e | f | |
|---|---|---|---|---|---|---|
| 2020-06-01 | NaN | -0.959554 | -0.367265 | 1.108948 | 0 | 0.149394 |
| 2020-06-02 | -0.412362 | 0.232540 | -1.903134 | -1.831848 | 0 | -1.599308 |
| 2020-06-03 | -1.898721 | 0.919976 | 0.485630 | 0.758721 | 0 | 1.678697 |
| 2020-06-04 | 0.486961 | 0.378323 | 0.186727 | 0.671816 | 0 | 1.050139 |
| 2020-06-05 | 0.702523 | -0.556798 | 0.635000 | -0.118564 | 0 | -0.675362 |
| 2020-06-06 | 0.654506 | -0.000727 | NaN | -0.611751 | 0 | -0.612478 |
df.rename(columns={'c':'cc'})
| a | b | cc | d | e | f | |
|---|---|---|---|---|---|---|
| 2020-06-01 | NaN | -0.959554 | -0.367265 | 1.108948 | 0 | 0.149394 |
| 2020-06-02 | -0.412362 | 0.232540 | -1.903134 | -1.831848 | 0 | -1.599308 |
| 2020-06-03 | -1.898721 | 0.919976 | 0.485630 | 0.758721 | 0 | 1.678697 |
| 2020-06-04 | 0.486961 | 0.378323 | 0.186727 | 0.671816 | 0 | 1.050139 |
| 2020-06-05 | 0.702523 | -0.556798 | 0.635000 | -0.118564 | 0 | -0.675362 |
| 2020-06-06 | 0.654506 | -0.000727 | NaN | -0.611751 | 0 | -0.612478 |
# 将a设置为索引
df.set_index('a')
| b | c | d | e | f | |
|---|---|---|---|---|---|
| a | |||||
| NaN | -0.959554 | -0.367265 | 1.108948 | 0 | 0.149394 |
| -0.412362 | 0.232540 | -1.903134 | -1.831848 | 0 | -1.599308 |
| -1.898721 | 0.919976 | 0.485630 | 0.758721 | 0 | 1.678697 |
| 0.486961 | 0.378323 | 0.186727 | 0.671816 | 0 | 1.050139 |
| 0.702523 | -0.556798 | 0.635000 | -0.118564 | 0 | -0.675362 |
| 0.654506 | -0.000727 | NaN | -0.611751 | 0 | -0.612478 |
df.drop(columns=['a', 'f'])
| b | c | d | e | |
|---|---|---|---|---|
| 2020-06-01 | -0.959554 | -0.367265 | 1.108948 | 0 |
| 2020-06-02 | 0.232540 | -1.903134 | -1.831848 | 0 |
| 2020-06-03 | 0.919976 | 0.485630 | 0.758721 | 0 |
| 2020-06-04 | 0.378323 | 0.186727 | 0.671816 | 0 |
| 2020-06-05 | -0.556798 | 0.635000 | -0.118564 | 0 |
| 2020-06-06 | -0.000727 | NaN | -0.611751 | 0 |
处理Nan数据¶
检查是否Nan值¶
df.isnull()
| a | b | c | d | e | f | |
|---|---|---|---|---|---|---|
| 2020-06-01 | True | False | False | False | False | False |
| 2020-06-02 | False | False | False | False | False | False |
| 2020-06-03 | False | False | False | False | False | False |
| 2020-06-04 | False | False | False | False | False | False |
| 2020-06-05 | False | False | False | False | False | False |
| 2020-06-06 | False | False | True | False | False | False |
df.notnull() # df.isnull()反操作
| a | b | c | d | e | f | |
|---|---|---|---|---|---|---|
| 2020-06-01 | False | True | True | True | True | True |
| 2020-06-02 | True | True | True | True | True | True |
| 2020-06-03 | True | True | True | True | True | True |
| 2020-06-04 | True | True | True | True | True | True |
| 2020-06-05 | True | True | True | True | True | True |
| 2020-06-06 | True | True | False | True | True | True |
删除掉包含null值的行¶
### dropna(axis=, how=):丢弃NaN数据,
# axis:0-按行丢弃),1-按列丢弃; how:'any'-只要含有NaN数据就丢弃,'all'-所有数据都为NaN时丢弃
df.dropna(axis=0)
| a | b | c | d | e | f | |
|---|---|---|---|---|---|---|
| 2020-06-02 | -0.412362 | 0.232540 | -1.903134 | -1.831848 | 0 | -1.599308 |
| 2020-06-03 | -1.898721 | 0.919976 | 0.485630 | 0.758721 | 0 | 1.678697 |
| 2020-06-04 | 0.486961 | 0.378323 | 0.186727 | 0.671816 | 0 | 1.050139 |
| 2020-06-05 | 0.702523 | -0.556798 | 0.635000 | -0.118564 | 0 | -0.675362 |
替换Nan¶
#### 使用1000替换Nan
df.fillna(1000)
| a | b | c | d | e | f | |
|---|---|---|---|---|---|---|
| 2020-06-01 | 1000.000000 | -0.959554 | -0.367265 | 1.108948 | 0 | 0.149394 |
| 2020-06-02 | -0.412362 | 0.232540 | -1.903134 | -1.831848 | 0 | -1.599308 |
| 2020-06-03 | -1.898721 | 0.919976 | 0.485630 | 0.758721 | 0 | 1.678697 |
| 2020-06-04 | 0.486961 | 0.378323 | 0.186727 | 0.671816 | 0 | 1.050139 |
| 2020-06-05 | 0.702523 | -0.556798 | 0.635000 | -0.118564 | 0 | -0.675362 |
| 2020-06-06 | 0.654506 | -0.000727 | 1000.000000 | -0.611751 | 0 | -0.612478 |
# 使用平均值替换所有null值
df.fillna(df.mean())
| a | b | c | d | e | f | |
|---|---|---|---|---|---|---|
| 2020-06-01 | -0.093418 | -0.959554 | -0.367265 | 1.108948 | 0 | 0.149394 |
| 2020-06-02 | -0.412362 | 0.232540 | -1.903134 | -1.831848 | 0 | -1.599308 |
| 2020-06-03 | -1.898721 | 0.919976 | 0.485630 | 0.758721 | 0 | 1.678697 |
| 2020-06-04 | 0.486961 | 0.378323 | 0.186727 | 0.671816 | 0 | 1.050139 |
| 2020-06-05 | 0.702523 | -0.556798 | 0.635000 | -0.118564 | 0 | -0.675362 |
| 2020-06-06 | 0.654506 | -0.000727 | -0.192608 | -0.611751 | 0 | -0.612478 |
df.mean()
a -0.093418 b 0.002293 c -0.192608 d -0.003780 e 0.000000 f -0.001486 dtype: float64
df.corr()
| a | b | c | d | e | f | |
|---|---|---|---|---|---|---|
| a | 1.000000 | -0.821088 | 0.055410 | -0.201634 | NaN | -0.486299 |
| b | -0.821088 | 1.000000 | 0.024617 | -0.127603 | NaN | 0.441503 |
| c | 0.055410 | 0.024617 | 1.000000 | 0.743462 | NaN | 0.682118 |
| d | -0.201634 | -0.127603 | 0.743462 | 1.000000 | NaN | 0.833588 |
| e | NaN | NaN | NaN | NaN | NaN | NaN |
| f | -0.486299 | 0.441503 | 0.682118 | 0.833588 | NaN | 1.000000 |
返回每一列中非null值数量¶
df.count()
a 5 b 6 c 5 d 6 e 6 f 6 dtype: int64
df.max()
a 0.702523 b 0.919976 c 0.635000 d 1.108948 e 0.000000 f 1.678697 dtype: float64
df.min()
a -1.898721 b -0.959554 c -1.903134 d -1.831848 e 0.000000 f -1.599308 dtype: float64
df.median()
a 0.486961 b 0.115907 c 0.186727 d 0.276626 e 0.000000 f -0.231542 dtype: float64
df.std()
a 1.105735 b 0.674432 c 1.030199 d 1.095503 e 0.000000 f 1.210962 dtype: float64
分组后取TopN¶
### 取每个国家下,分值前二的记录
# 先排序
df4 = df3.sort_values(['country','score'],ascending=[1, 0],inplace=False)
df4
| name | age | gender | score | country | |
|---|---|---|---|---|---|
| 1 | Molly | 32 | F | 94 | CN |
| 4 | Amy | 23 | F | 70 | CN |
| 6 | Tim | 27 | F | 67 | CN |
| 2 | Tina | 36 | F | 57 | CN |
| 5 | Jack | 18 | F | 88 | US |
| 3 | Jake | 24 | F | 62 | US |
| 0 | Jason | 20 | F | 25 | US |
# 取值
df4.groupby(['country']).head(2)
| name | age | gender | score | country | |
|---|---|---|---|---|---|
| 1 | Molly | 32 | F | 94 | CN |
| 4 | Amy | 23 | F | 70 | CN |
| 5 | Jack | 18 | F | 88 | US |
| 3 | Jake | 24 | F | 62 | US |
多重分组后取TopN¶
### 取每个国家下,分值前二的记录
# 先排序
df5 = df3.sort_values(['country','gender', 'score'],ascending=[1, 0, 0],inplace=False)
df5
| name | age | gender | score | country | |
|---|---|---|---|---|---|
| 1 | Molly | 32 | F | 94 | CN |
| 4 | Amy | 23 | F | 70 | CN |
| 6 | Tim | 27 | F | 67 | CN |
| 2 | Tina | 36 | F | 57 | CN |
| 5 | Jack | 18 | F | 88 | US |
| 3 | Jake | 24 | F | 62 | US |
| 0 | Jason | 20 | F | 25 | US |
df5 = df5.groupby(['country', 'gender']).head(1) # 注意此处取1
df5
| name | age | gender | score | country | |
|---|---|---|---|---|---|
| 1 | Molly | 32 | F | 94 | CN |
| 5 | Jack | 18 | F | 88 | US |
df5.groupby(['country']).head(2)
| name | age | gender | score | country | |
|---|---|---|---|---|---|
| 1 | Molly | 32 | F | 94 | CN |
| 5 | Jack | 18 | F | 88 | US |
scoreMean = df3.groupby(['gender'])['score'].mean()
scoreMean = pd.DataFrame(scoreMean) # 等效于socreMean = scoreMean.to_frame()
scoreMean
| score | |
|---|---|
| gender | |
| F | 66.142857 |
#### 合并
df3.merge(scoreMean,left_on='gender',right_index=True)
| name | age | gender | score_x | country | score_y | |
|---|---|---|---|---|---|---|
| 0 | Jason | 20 | F | 25 | US | 66.142857 |
| 1 | Molly | 32 | F | 94 | CN | 66.142857 |
| 2 | Tina | 36 | F | 57 | CN | 66.142857 |
| 3 | Jake | 24 | F | 62 | US | 66.142857 |
| 4 | Amy | 23 | F | 70 | CN | 66.142857 |
| 5 | Jack | 18 | F | 88 | US | 66.142857 |
| 6 | Tim | 27 | F | 67 | CN | 66.142857 |
df3
| name | age | gender | score | country | |
|---|---|---|---|---|---|
| 0 | Jason | 20 | F | 25 | US |
| 1 | Molly | 32 | F | 94 | CN |
| 2 | Tina | 36 | F | 57 | CN |
| 3 | Jake | 24 | F | 62 | US |
| 4 | Amy | 23 | F | 70 | CN |
| 5 | Jack | 18 | F | 88 | US |
| 6 | Tim | 27 | F | 67 | CN |
df3.groupby(['country'])['gender'].count().to_frame()
| gender | |
|---|---|
| country | |
| CN | 4 |
| US | 3 |
### 按性别统计每个国家的人数
df3.groupby(['country', 'gender'])['gender'].count().to_frame()
| gender | ||
|---|---|---|
| country | gender | |
| CN | F | 4 |
| US | F | 3 |
df3.groupby(['country'])['gender'].nunique().to_frame()
| gender | |
|---|---|
| country | |
| CN | 1 |
| US | 1 |
# 默认是所有数值类型列求和
df3.groupby('country').sum()
| age | score | |
|---|---|---|
| country | ||
| CN | 118 | 288 |
| US | 62 | 175 |
# 指定列求和
df3.groupby('country')['score'].sum() # 等效于df3.groupby(['country'])['score'].apply(np.sum)
country CN 288 US 175 Name: score, dtype: int64
import matplotlib.pyplot as plt
plt.clf()
df3.groupby('country').sum().plot(kind='bar')
plt.show()
<Figure size 432x288 with 0 Axes>
df3.groupby('country')['score'].sum().plot(kind='bar')
<matplotlib.axes._subplots.AxesSubplot at 0x7f4942ae96a0>
df3.groupby('country').agg({'score':['min','max','mean']})
| score | |||
|---|---|---|---|
| min | max | mean | |
| country | |||
| CN | 57 | 94 | 72.000000 |
| US | 25 | 88 | 58.333333 |
# 跟上面效果一致
df3.groupby('country')['score'].agg([np.min, np.max, np.mean])
| amin | amax | mean | |
|---|---|---|---|
| country | |||
| CN | 57 | 94 | 72.000000 |
| US | 25 | 88 | 58.333333 |
df3.groupby('country').agg({'score': ['max','min', 'std'],
'age': ['sum', 'count', 'max']})
| score | age | |||||
|---|---|---|---|---|---|---|
| max | min | std | sum | count | max | |
| country | ||||||
| CN | 94 | 57 | 15.684387 | 118 | 4 | 36 |
| US | 88 | 25 | 31.659648 | 62 | 3 | 24 |
t1=df3.groupby('country')['score'].mean().to_frame()
t2 = df3.groupby('country')['age'].sum().to_frame()
t1.merge(t2,left_index=True,right_index=True)
| score | age | |
|---|---|---|
| country | ||
| CN | 72.000000 | 118 |
| US | 58.333333 | 62 |
grouped = df3.groupby('country')
for name,group in grouped:
print(name)
print(group)
CN
name age gender score country
1 Molly 32 F 94 CN
2 Tina 36 F 57 CN
4 Amy 23 F 70 CN
6 Tim 27 F 67 CN
US
name age gender score country
0 Jason 20 F 25 US
3 Jake 24 F 62 US
5 Jack 18 F 88 US
grouped = df3.groupby(['country', 'gender'])
for name,group in grouped:
print(name)
print(group)
('CN', 'F')
name age gender score country
1 Molly 32 F 94 CN
2 Tina 36 F 57 CN
4 Amy 23 F 70 CN
6 Tim 27 F 67 CN
('US', 'F')
name age gender score country
0 Jason 20 F 25 US
3 Jake 24 F 62 US
5 Jack 18 F 88 US
df3.groupby('country').groups
{'CN': Int64Index([1, 2, 4, 6], dtype='int64'),
'US': Int64Index([0, 3, 5], dtype='int64')}
df3.groupby('country').get_group('CN')
| name | age | gender | score | country | |
|---|---|---|---|---|---|
| 1 | Molly | 32 | F | 94 | CN |
| 2 | Tina | 36 | F | 57 | CN |
| 4 | Amy | 23 | F | 70 | CN |
| 6 | Tim | 27 | F | 67 | CN |
df3.groupby('name').filter(lambda x: len(x) >= 3)
| name | age | gender | score | country |
|---|
# 数据透视的值项只能是数值类型
# pivot(index =,columns=,values=):透视数据
# index:透视的列(作为索引, 且值都是唯一的); columns-用于进一步细分index;values查看具体值
df3.pivot(index ='name',columns='gender',values=['score','age'])
| score | age | |
|---|---|---|
| gender | F | F |
| name | ||
| Amy | 70 | 23 |
| Jack | 88 | 18 |
| Jake | 62 | 24 |
| Jason | 25 | 20 |
| Molly | 94 | 32 |
| Tim | 67 | 27 |
| Tina | 57 | 36 |
# pivot_table(index =,columns=,values=):透视数据
# index:透视的列(作为索引, 且值都是唯一的); columns-用于进一步细分index;values查看具体值;fill_value:0-用0替换Nan; margins:True-汇总
pd.pivot_table(df3,index=['country', 'gender'], values=['score'],aggfunc=np.sum)
| score | ||
|---|---|---|
| country | gender | |
| CN | F | 288 |
| US | F | 175 |
pd.pivot_table(df3,index=['country', 'gender'], values=['score', 'age'],aggfunc=[np.sum, np.mean],fill_value=0,margins=True)
| sum | mean | ||||
|---|---|---|---|---|---|
| age | score | age | score | ||
| country | gender | ||||
| CN | F | 118 | 288 | 29.500000 | 72.000000 |
| US | F | 62 | 175 | 20.666667 | 58.333333 |
| All | 180 | 463 | 25.714286 | 66.142857 | |
df3
| name | age | gender | score | country | |
|---|---|---|---|---|---|
| 0 | Jason | 20 | F | 25 | US |
| 1 | Molly | 32 | F | 94 | CN |
| 2 | Tina | 36 | F | 57 | CN |
| 3 | Jake | 24 | F | 62 | US |
| 4 | Amy | 23 | F | 70 | CN |
| 5 | Jack | 18 | F | 88 | US |
| 6 | Tim | 27 | F | 67 | CN |
合并、连接、拼接(Merge, join, and concatenate)¶
拼接(concatenate)¶
t1 = pd.DataFrame({'A': ['A0', 'A1', 'A2', 'A3'],
'B': ['B0', 'B1', 'B2', 'B3'],
'C': ['C0', 'C1', 'C2', 'C3'],
'D': ['D0', 'D1', 'D2', 'D3']},
index=[0, 1, 2, 3])
print('-----t1----')
display(t1)
t2 = pd.DataFrame({'A': ['A4', 'A5', 'A6', 'A7'],
'B': ['B4', 'B5', 'B6', 'B7'],
'C': ['C4', 'C5', 'C6', 'C7'],
'D': ['D4', 'D5', 'D6', 'D7']},
index=[4, 5, 6, 7])
print('----t2-----')
display(t2)
t3 = pd.DataFrame({'A': ['A8', 'A9', 'A10', 'A11'],
'B': ['B8', 'B9', 'B10', 'B11'],
'C': ['C8', 'C9', 'C10', 'C11'],
'D': ['D8', 'D9', 'D10', 'D11']},
index=[8, 9, 10, 11])
print('-----t3----')
print(t2)
frames = [t1, t2, t3]
pd.concat(frames)
-----t1----
| A | B | C | D | |
|---|---|---|---|---|
| 0 | A0 | B0 | C0 | D0 |
| 1 | A1 | B1 | C1 | D1 |
| 2 | A2 | B2 | C2 | D2 |
| 3 | A3 | B3 | C3 | D3 |
----t2-----
| A | B | C | D | |
|---|---|---|---|---|
| 4 | A4 | B4 | C4 | D4 |
| 5 | A5 | B5 | C5 | D5 |
| 6 | A6 | B6 | C6 | D6 |
| 7 | A7 | B7 | C7 | D7 |
-----t3----
A B C D
4 A4 B4 C4 D4
5 A5 B5 C5 D5
6 A6 B6 C6 D6
7 A7 B7 C7 D7
| A | B | C | D | |
|---|---|---|---|---|
| 0 | A0 | B0 | C0 | D0 |
| 1 | A1 | B1 | C1 | D1 |
| 2 | A2 | B2 | C2 | D2 |
| 3 | A3 | B3 | C3 | D3 |
| 4 | A4 | B4 | C4 | D4 |
| 5 | A5 | B5 | C5 | D5 |
| 6 | A6 | B6 | C6 | D6 |
| 7 | A7 | B7 | C7 | D7 |
| 8 | A8 | B8 | C8 | D8 |
| 9 | A9 | B9 | C9 | D9 |
| 10 | A10 | B10 | C10 | D10 |
| 11 | A11 | B11 | C11 | D11 |
t4 = pd.DataFrame({'B': ['B2', 'B3', 'B6', 'B7'],
'D': ['D2', 'D3', 'D6', 'D7'],
'F': ['F2', 'F3', 'F6', 'F7']},
index=[2, 3, 6, 7])
print('-----t4----')
pd.concat([t1, t4], axis=1, sort=False) # 此时相当于out joiner
-----t4----
| A | B | C | D | B | D | F | |
|---|---|---|---|---|---|---|---|
| 0 | A0 | B0 | C0 | D0 | NaN | NaN | NaN |
| 1 | A1 | B1 | C1 | D1 | NaN | NaN | NaN |
| 2 | A2 | B2 | C2 | D2 | B2 | D2 | F2 |
| 3 | A3 | B3 | C3 | D3 | B3 | D3 | F3 |
| 6 | NaN | NaN | NaN | NaN | B6 | D6 | F6 |
| 7 | NaN | NaN | NaN | NaN | B7 | D7 | F7 |
pd.concat([t1, t4], axis=1, join='inner')
| A | B | C | D | B | D | F | |
|---|---|---|---|---|---|---|---|
| 2 | A2 | B2 | C2 | D2 | B2 | D2 | F2 |
| 3 | A3 | B3 | C3 | D3 | B3 | D3 | F3 |
t1.append([t2,t3]) # 相当于pd.concat([t1, t2, t3])
| A | B | C | D | |
|---|---|---|---|---|
| 0 | A0 | B0 | C0 | D0 |
| 1 | A1 | B1 | C1 | D1 |
| 2 | A2 | B2 | C2 | D2 |
| 3 | A3 | B3 | C3 | D3 |
| 4 | A4 | B4 | C4 | D4 |
| 5 | A5 | B5 | C5 | D5 |
| 6 | A6 | B6 | C6 | D6 |
| 7 | A7 | B7 | C7 | D7 |
| 8 | A8 | B8 | C8 | D8 |
| 9 | A9 | B9 | C9 | D9 |
| 10 | A10 | B10 | C10 | D10 |
| 11 | A11 | B11 | C11 | D11 |
连接(Join)¶
join(on=None, how='left', lsuffix='', rsuffix='', sort=False)
on:join的键,默认是矩阵的index, how:join方式,left-相当于左连接,outer,inner
更多查看Database-style DataFrame or named Series joining/merging
left = pd.DataFrame({'A': ['A0', 'A1', 'A2'],
'B': ['B0', 'B1', 'B2']},
index=['K0', 'K1', 'K2'])
print('----left----')
display(left)
right = pd.DataFrame({'C': ['C0', 'C2', 'C3'],
'D': ['D0', 'D2', 'D3']},
index=['K0', 'K2', 'K3'])
print('---right----')
display(right)
left.join(right) # 相当于 pd.merge(left, right, left_index=True, right_index=True, how='left')
----left----
| A | B | |
|---|---|---|
| K0 | A0 | B0 |
| K1 | A1 | B1 |
| K2 | A2 | B2 |
---right----
| C | D | |
|---|---|---|
| K0 | C0 | D0 |
| K2 | C2 | D2 |
| K3 | C3 | D3 |
| A | B | C | D | |
|---|---|---|---|---|
| K0 | A0 | B0 | C0 | D0 |
| K1 | A1 | B1 | NaN | NaN |
| K2 | A2 | B2 | C2 | D2 |
left.join(right, how='outer') # 相当于pd.merge(left, right, left_index=True, right_index=True, how='outer')
| A | B | C | D | |
|---|---|---|---|---|
| K0 | A0 | B0 | C0 | D0 |
| K1 | A1 | B1 | NaN | NaN |
| K2 | A2 | B2 | C2 | D2 |
| K3 | NaN | NaN | C3 | D3 |
left.join(right, how='inner') #相当于pd.merge(left, right, left_index=True, right_index=True, how='inner')
| A | B | C | D | |
|---|---|---|---|---|
| K0 | A0 | B0 | C0 | D0 |
| K2 | A2 | B2 | C2 | D2 |
根据某一列进行join¶
left.join(right, on=key_or_keys)= pd.merge(left, right, left_on=key_or_keys, right_index=True, how='left', sort=False) // 使用left矩阵的key_or_keys列与right矩阵的index进行join
left = pd.DataFrame({'A': ['A0', 'A1', 'A2', 'A3'],
'B': ['B0', 'B1', 'B2', 'B3'],
'key': ['K0', 'K1', 'K0', 'K1']})
print('----left----')
print(left)
right = pd.DataFrame({'C': ['C0', 'C1'],
'D': ['D0', 'D1']},
index=['K0', 'K1'])
print('----right----')
display(right)
left.join(right, on='key') # 相当于pd.merge(left, right, left_on='key', right_index=True,how='left', sort=False);
----left----
A B key
0 A0 B0 K0
1 A1 B1 K1
2 A2 B2 K0
3 A3 B3 K1
----right----
| C | D | |
|---|---|---|
| K0 | C0 | D0 |
| K1 | C1 | D1 |
| A | B | key | C | D | |
|---|---|---|---|---|---|
| 0 | A0 | B0 | K0 | C0 | D0 |
| 1 | A1 | B1 | K1 | C1 | D1 |
| 2 | A2 | B2 | K0 | C0 | D0 |
| 3 | A3 | B3 | K1 | C1 | D1 |
#### 多列的join
left = pd.DataFrame({'A': ['A0', 'A1', 'A2', 'A3'],
'B': ['B0', 'B1', 'B2', 'B3'],
'key1': ['K0', 'K0', 'K1', 'K2'],
'key2': ['K0', 'K1', 'K0', 'K1']})
print('----left----')
display(left)
index = pd.MultiIndex.from_tuples([('K0', 'K0'), ('K1', 'K0'),
('K2', 'K0'), ('K3', 'K11')])
right = pd.DataFrame({'C': ['C0', 'C1', 'C2', 'C3'],
'D': ['D0', 'D1', 'D2', 'D3']},
index=index)
print('----right----')
display(right)
left.join(right, on=['key1', 'key2'])
----left----
| A | B | key1 | key2 | |
|---|---|---|---|---|
| 0 | A0 | B0 | K0 | K0 |
| 1 | A1 | B1 | K0 | K1 |
| 2 | A2 | B2 | K1 | K0 |
| 3 | A3 | B3 | K2 | K1 |
----right----
| C | D | ||
|---|---|---|---|
| K0 | K0 | C0 | D0 |
| K1 | K0 | C1 | D1 |
| K2 | K0 | C2 | D2 |
| K3 | K11 | C3 | D3 |
| A | B | key1 | key2 | C | D | |
|---|---|---|---|---|---|---|
| 0 | A0 | B0 | K0 | K0 | C0 | D0 |
| 1 | A1 | B1 | K0 | K1 | NaN | NaN |
| 2 | A2 | B2 | K1 | K0 | C1 | D1 |
| 3 | A3 | B3 | K2 | K1 | NaN | NaN |
left.join(right, on=['key1', 'key2'], how='inner')
| A | B | key1 | key2 | C | D | |
|---|---|---|---|---|---|---|
| 0 | A0 | B0 | K0 | K0 | C0 | D0 |
| 2 | A2 | B2 | K1 | K0 | C1 | D1 |
从csv中导入数据¶
pd.read_csv('../dataset/game_daily_stats_20200127_20200202.csv', names=['id', '日期', '游戏id', '游戏名称', '国家', '国家码', '下载数', '下载用户数', '成功下载数', '成功下载用户数','安装数', '安装用户数'],na_filter = False)
| id | 日期 | 游戏id | 游戏名称 | 国家 | 国家码 | 下载数 | 下载用户数 | 成功下载数 | 成功下载用户数 | 安装数 | 安装用户数 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 7564316 | 2020-01-27 | 1 | Uphill Rush Water Park Racing | 俄罗斯 | RU | 1 | 1 | 1 | 1 | 1 | 1 |
| 1 | 7564317 | 2020-01-27 | 1 | Uphill Rush Water Park Racing | 肯尼亚 | KE | 2 | 2 | 2 | 2 | 0 | 0 |
| 2 | 7564318 | 2020-01-27 | 1 | Uphill Rush Water Park Racing | 刚果金 | CD | 1 | 1 | 0 | 0 | 0 | 0 |
| 3 | 7564319 | 2020-01-27 | 1 | Uphill Rush Water Park Racing | 尼泊尔 | NP | 1 | 1 | 0 | 0 | 0 | 0 |
| 4 | 7564320 | 2020-01-27 | 1 | Uphill Rush Water Park Racing | 索马里 | SO | 1 | 1 | 1 | 1 | 1 | 1 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 179886 | 8010481 | 2020-02-02 | 175 | Soccer Star 2022 World Legend: Football game | 赞比亚 | ZM | 2 | 2 | 0 | 0 | 0 | 0 |
| 179887 | 8010482 | 2020-02-02 | 175 | Soccer Star 2022 World Legend: Football game | 尼日利亚 | NG | 1 | 1 | 2 | 2 | 2 | 2 |
| 179888 | 8010483 | 2020-02-02 | 175 | Soccer Star 2022 World Legend: Football game | 埃及 | EG | 2 | 2 | 0 | 0 | 0 | 0 |
| 179889 | 8010484 | 2020-02-02 | 175 | Soccer Star 2022 World Legend: Football game | 科特迪瓦 | CI | 3 | 3 | 2 | 2 | 2 | 2 |
| 179890 | 8010485 | 2020-02-02 | 175 | Soccer Star 2022 World Legend: Football game | 约旦 | JO | 1 | 1 | 0 | 0 | 0 | 0 |
179891 rows × 12 columns
导出数据到csv¶
df.to_csv('/tmp/pandas.csv', encoding="utf_8_sig")
Spark上手示例 ↵
Spark上手示例1:RDD操作
# 引入pyspark,并创建spark上下文
import findspark
findspark.init()
import pyspark
sc = pyspark.SparkContext()
1. 创建RDD的第一种方式,读外部数据,比如本地磁盘文件¶
rdd = sc.textFile('./dataset/Goodbye_Object_Oriented_Programming.txt')
# 查看rdd类型
type(rdd)
pyspark.rdd.RDD
1.1 RDD之转换(Transformation)¶
%%time
## map是转换操作的一种,这时候只是形成DAG
rdd = rdd.map(lambda x: len(x))
CPU times: user 0 ns, sys: 0 ns, total: 0 ns Wall time: 31 µs
1.2 RDD之行动(Action)¶
%%time
## reduce是行动操作的一种, 这个时候才真正的计算
charCount = rdd.reduce(lambda x, y: x+y)
print(charCount)
13187 CPU times: user 12 ms, sys: 0 ns, total: 12 ms Wall time: 1.58 s
! wc ./dataset/Goodbye_Object_Oriented_Programming.txt
328 2260 13687 ./dataset/Goodbye_Object_Oriented_Programming.txt
1.3 示例:统计单词出现的次数¶
wordRdd = sc.textFile('./dataset/Goodbye_Object_Oriented_Programming.txt')
# take操作就是一种Action, 返回前n数据
wordRdd.take(2)
['I’ve been programming in Object Oriented languages for decades. The first OO language I used was C++ and then Smalltalk and finally .NET and Java.', '']
# 将每一行文本打散
wordRdd = wordRdd.map(lambda line: line.split(' '))
wordRdd.take(2)
[['I’ve', 'been', 'programming', 'in', 'Object', 'Oriented', 'languages', 'for', 'decades.', 'The', 'first', 'OO', 'language', 'I', 'used', 'was', 'C++', 'and', 'then', 'Smalltalk', 'and', 'finally', '.NET', 'and', 'Java.'], ['']]
# 扁平化处理
wordRdd = wordRdd.flatMap(lambda x: x)
# 查看有多少个单词
wordRdd.count()
2493
# 查看前两条数据
wordRdd.take(2)
['I’ve', 'been']
# 过滤掉空格数据
wordRdd = wordRdd.filter(lambda x: x != '')
# 查看有多少个单词
wordRdd.count()
2260
# 转换成key-value形式rdd 即 (key, value)
wordRdd = wordRdd.map(lambda word: (word, 1))
wordRdd.take(2)
[('I’ve', 1), ('been', 1)]
wordRdd = wordRdd.reduceByKey(lambda x, y: x+y)
# 查看一下
wordRdd.take(10)
# 查看全部
# wordRdd.collect()
[('face', 1),
('was', 18),
('Monkey', 2),
('how', 4),
('Just', 1),
('for', 11),
('Directories', 1),
('could', 4),
('gained', 1),
('AGAIN', 1)]
# 使用pandas继续计算
import pandas as pd
df = pd.DataFrame(wordRdd.collect())
# 设置栏位名字
df.columns = ['word', 'count']
# 查看前10条数据
df.head(10)
| word | count | |
|---|---|---|
| 0 | face | 1 |
| 1 | was | 18 |
| 2 | Monkey | 2 |
| 3 | how | 4 |
| 4 | Just | 1 |
| 5 | for | 11 |
| 6 | Directories | 1 |
| 7 | could | 4 |
| 8 | gained | 1 |
| 9 | AGAIN | 1 |
# 查看出现次数最多的十个单词
df =df.sort_values(by='count', ascending=False)
df.head(10)
| word | count | |
|---|---|---|
| 263 | the | 121 |
| 271 | to | 57 |
| 576 | of | 47 |
| 358 | and | 45 |
| 589 | a | 41 |
| 797 | is | 38 |
| 136 | in | 35 |
| 593 | I | 32 |
| 685 | that | 29 |
| 645 | The | 26 |
# 停止spark上下文
sc.stop()
Spark上手示例2:DataFrame操作
# 导入相关库
from pyspark.sql import Row, SparkSession,SQLContext
from pyspark.sql.types import IntegerType,DateType, TimestampType
from pyspark.sql.functions import col, udf,to_date,from_unixtime,countDistinct
# 计算处理
import pandas as pd
import numpy as np
import time
# 图表相关
import plotly.plotly as py
import plotly
plotly.offline.init_notebook_mode(connected=True)
import plotly.graph_objs as go
# jupyter使用matplot的配置
%matplotlib inline
# 创建spark上下文,并设置10个分区
spark = SparkSession.builder.appName("vas项目").config("spark.default.parallelism", 10).getOrCreate()
sc = spark.sparkContext
%%time
logPaths = ['/var/log/vas-project/vas_data/201807',
'/var/log/vas-project/vas_data/201808',
'/var/log/vas-project/vas_data/201809',
'/var/log/vas-project/vas_data/201810'
];
df = spark.read.format('json').load(logPaths)
CPU times: user 8 ms, sys: 0 ns, total: 8 ms Wall time: 41.3 s
%%time
# 查看前10条数据
df.limit(10).show()
+-----+------------+------+--------------------+---------------+-----+-----------+---+ |brand|country_code|device| events| ip_address|model| partner|ref| +-----+------------+------+--------------------+---------------+-----+-----------+---+ | Itel| ML| sp|[[click, 15358391...| 217.64.103.74| P13|searchturbo| m| | Itel| EG| sp|[[click, 15358391...|196.141.135.133| A32F|searchturbo| m| | Itel| NG| sp|[[click, 15358391...| 197.210.226.58| P32|searchturbo| m| | Itel| IN| sp|[[click, 15358391...| 157.48.123.237| A22|searchturbo| m| | Itel| EG| sp|[[click, 15358391...| 105.199.93.33| A32F|searchturbo| m| | Itel| EG| sp|[[click, 15358391...|196.141.135.133| A32F|searchturbo| m| | Itel| MA| sp|[[click, 15358391...| 41.249.147.213| A32F|searchturbo| m| | Itel| CI| sp|[[click, 15358391...| 154.0.26.115| P32| Unknown| m| | Itel| BJ| sp|[[click, 15358391...|197.234.221.243| A32F|searchturbo| m| | Itel| EG| sp|[[click, 15358391...|196.141.135.133| A32F|searchturbo| m| +-----+------------+------+--------------------+---------------+-----+-----------+---+ CPU times: user 0 ns, sys: 0 ns, total: 0 ns Wall time: 11.6 s
%%time
# 查看总共记录数
df.count()
CPU times: user 0 ns, sys: 0 ns, total: 0 ns Wall time: 6.31 s
2075513
例1. 按品牌机型统计¶
%%time
# 按照品牌(brand)和机型(model)进行聚合
brand_model_count = df.select('brand', 'partner', 'model').groupBy('brand','model').count()
# 打印一下
brand_model_count.show()
+-----+---------+------+ |brand| model| count| +-----+---------+------+ | Itel| A52B| 19| | Itel| A14| 10136| | Itel| S13 Pro| 151| | Itel| A16 Plus| 229| | Itel| A52| 12812| | Itel| A45| 68690| | Itel| A22| 69811| | Itel| A16| 4210| | Itel| S11X| 27366| | Itel| A62| 11161| |Spice| Z213| 77393| | Itel| S11XB| 137| | Itel| A15| 11744| | Itel| P32|550753| | Itel| P13 Plus| 176| | Itel|A44 Power| 32| | Itel| A32F|537792| | Itel|itel A32F| 67001| | Itel| A23| 1493| | Itel| S13| 19634| +-----+---------+------+ only showing top 20 rows CPU times: user 8 ms, sys: 0 ns, total: 8 ms Wall time: 10.8 s
%%time
# 换行成pandas
pd_df = brand_model_count.toPandas()
# 查看前5条
pd_df.head(5)
CPU times: user 4 ms, sys: 8 ms, total: 12 ms Wall time: 10.1 s
# 数据转换,排序处理下
pd_df.index = pd_df['brand'] + '_' + pd_df['model']
pd_df = pd_df.sort_values(by = ['brand', 'count'])
pd_df.head()
| brand | model | count | |
|---|---|---|---|
| Itel_S13Pro | Itel | S13Pro | 3 |
| Itel_A52B | Itel | A52B | 19 |
| Itel_A44 Power | Itel | A44 Power | 32 |
| Itel_S11XB | Itel | S11XB | 137 |
| Itel_S13 Pro | Itel | S13 Pro | 151 |
# 只取count列
pd_df = pd_df[['count']]
# 查看一下
pd_df.head()
| count | |
|---|---|
| Itel_S13Pro | 3 |
| Itel_A52B | 19 |
| Itel_A44 Power | 32 |
| Itel_S11XB | 137 |
| Itel_S13 Pro | 151 |
# 图表显示下
pd_df.plot(kind='bar', figsize=(15, 5))
<matplotlib.axes._subplots.AxesSubplot at 0x7fa5fcf692b0>
%%time
# 按照country_code 进行聚合
country_code_count = df.select('country_code').groupBy('country_code').count()
CPU times: user 8 ms, sys: 0 ns, total: 8 ms Wall time: 62.6 ms
%%time
# 显示前5条数据
country_code_count.limit(5).collect()
CPU times: user 4 ms, sys: 0 ns, total: 4 ms Wall time: 7.5 s
[Row(country_code='DZ', count=1027), Row(country_code='LT', count=12), Row(country_code='MM', count=18), Row(country_code='CI', count=95814), Row(country_code='SC', count=8)]
# 转换成pandas
codePandas = country_code_count.toPandas()
codePandas.head()
| country_code | count | |
|---|---|---|
| 0 | DZ | 1027 |
| 1 | LT | 12 |
| 2 | MM | 18 |
| 3 | CI | 95814 |
| 4 | SC | 8 |
# 按照访问量排序下
codePandas = codePandas.sort_values(by='count', ascending=False)
codePandas.head(10)
| country_code | count | |
|---|---|---|
| 74 | IN | 440134 |
| 139 | NG | 432983 |
| 58 | Unknown | 309625 |
| 39 | BD | 192363 |
| 146 | VN | 96927 |
| 3 | CI | 95814 |
| 130 | MA | 69185 |
| 33 | GH | 49175 |
| 77 | CM | 44962 |
| 115 | SN | 42320 |
# 图表展示下
codes = {}
for code in codePandas.values[:30]:
codes[code[0].lower()] = code[1]
import pygal
from ipywidgets import HTML
import base64
worldmap_chart = pygal.maps.world.World()
worldmap_chart.title = '访问量最多的30个国家'
worldmap_chart.add('访问量top30', codes)
b64 = base64.b64encode(worldmap_chart.render())
src = 'data:image/svg+xml;charset=utf-8;base64,'+b64.decode("utf-8")
HTML('<embed src={}></embed>'.format(src))
HTML(value='<embed src=data:image/svg+xml;charset=utf-8;base64,PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0ndXRmLTg…
例3. 每小时访问量走势图¶
%%time
# 增加3个栏位, timestamp, hour, month
df = df.withColumn('timestamp', df.events[0].timestamp)
df = df.withColumn('hour', from_unixtime(df.events[0].timestamp, 'HH'))
df = df.withColumn('month', from_unixtime(df.events[0].timestamp, 'yyyy-MM'))
CPU times: user 4 ms, sys: 0 ns, total: 4 ms Wall time: 387 ms
%%time
# 按照小时聚合下
group_by_hour = df.select('hour').groupBy('hour').count()
df.show(10)
+-----+------------+------+--------------------+---------------+-----+-----------+---+----------+----+-------+ |brand|country_code|device| events| ip_address|model| partner|ref| timestamp|hour| month| +-----+------------+------+--------------------+---------------+-----+-----------+---+----------+----+-------+ | Itel| ML| sp|[[click, 15358391...| 217.64.103.74| P13|searchturbo| m|1535839148| 05|2018-09| | Itel| EG| sp|[[click, 15358391...|196.141.135.133| A32F|searchturbo| m|1535839156| 05|2018-09| | Itel| NG| sp|[[click, 15358391...| 197.210.226.58| P32|searchturbo| m|1535839161| 05|2018-09| | Itel| IN| sp|[[click, 15358391...| 157.48.123.237| A22|searchturbo| m|1535839162| 05|2018-09| | Itel| EG| sp|[[click, 15358391...| 105.199.93.33| A32F|searchturbo| m|1535839163| 05|2018-09| | Itel| EG| sp|[[click, 15358391...|196.141.135.133| A32F|searchturbo| m|1535839164| 05|2018-09| | Itel| MA| sp|[[click, 15358391...| 41.249.147.213| A32F|searchturbo| m|1535839167| 05|2018-09| | Itel| CI| sp|[[click, 15358391...| 154.0.26.115| P32| Unknown| m|1535839174| 05|2018-09| | Itel| BJ| sp|[[click, 15358391...|197.234.221.243| A32F|searchturbo| m|1535839174| 05|2018-09| | Itel| EG| sp|[[click, 15358391...|196.141.135.133| A32F|searchturbo| m|1535839175| 05|2018-09| +-----+------------+------+--------------------+---------------+-----+-----------+---+----------+----+-------+ only showing top 10 rows CPU times: user 4 ms, sys: 0 ns, total: 4 ms Wall time: 370 ms
%%time
# dataframe 转换成padnas
group_by_hour_pandas_df = group_by_hour.toPandas()
CPU times: user 8 ms, sys: 4 ms, total: 12 ms Wall time: 10.4 s
# 按照访问量进行排序下
group_by_hour_pandas_df = group_by_hour_pandas_df.sort_values(by='hour')
# 强制转换整数类型
group_by_hour_pandas_df.hour = group_by_hour_pandas_df['hour'].map(int)
# 将小时设置pandas索引
group_by_hour_pandas_df.index = group_by_hour_pandas_df.hour
group_by_hour_pandas_df.head(5)
| hour | count | |
|---|---|---|
| hour | ||
| 0 | 0 | 118156 |
| 1 | 1 | 112722 |
| 2 | 2 | 85696 |
| 3 | 3 | 85365 |
| 4 | 4 | 77598 |
# 画个图表
hourcount = (group_by_hour_pandas_df.to_dict())['count']
index = list(range(0,24))
cols = []
for i in index:
if i not in hourcount:
cols.append(0)
else:
cols.append(hourcount[i])
group_by_hour_pandas_df = pd.DataFrame({'num': cols})
group_by_hour_pandas_df.plot(title='vas project-access count by hour', kind='line', figsize=(15, 5), xticks=group_by_hour_pandas_df.index)
<matplotlib.axes._subplots.AxesSubplot at 0x7fa5fc0c82e8>
例4. 每个月访问量¶
%%time
group_by_month =df.select('month').groupBy('month').count()
group_by_month_pandas = group_by_month.limit(5).toPandas()
group_by_month_pandas.index = group_by_month_pandas['month']
group_by_month_pandas.index.name = 'm';
group_by_month_pandas = group_by_month_pandas.sort_values(by='month', ascending=True)
## 每月uv
group_by_month_pandas[['count']].plot(kind='bar', figsize=(15, 5))
CPU times: user 116 ms, sys: 0 ns, total: 116 ms Wall time: 10.3 s
Ended: Spark上手示例
Ended: Jupyter
视频 ↵
操作系统¶
Computer Organization and Design Fundamentals¶
Chill OS dev gardening¶
CS305@IITB: Computer Architecture¶
IIT Bombay's UG course on Computer Architecture (July-November 2021).
For slides and others: Please refer https://www.cse.iitb.ac.in/~biswa/courses/CS305/main.html
ACK: Many of the slides are adapted and modified versions of some of the excellent computer architecture courses taught by Joel Emer, Arvind, Yale Patt, Nima Honarmand, Hal Perkins, John Kubiatowicz, Onur Mutlu, Krste Asanovic, David Black-Schaffer, Rajeev Balasubramonian, and Mainak Chaudhuri.
CS773: Computer Architecture for Performance and Security¶
General Software Engineering Topics¶
Introduction to CPU Pipelining¶
Source Dive¶
What is a spinlock?¶
In this installment of //Source Dive//, we're back in the xv6 OS codebase, exploring timers, the early boot process, and a very useful concurrency primitive: The Spinlock!
The xv6 Kernel¶
This series introduces, describes, and explains "xv6", which is a simple Unix operating system. We look at the overall design and walk through the C code. This short course is appropriate for a university student interested in Unix/Linux kernel implementation. The x86 and RISC-V implementations are almost identical; we use the RISC-V implementation here.
https://www.youtube.com/playlist?list=PLbtzT1TYeoMhTPzyTZboW_j7TPAnjv9XB
Circuit Breaker Pattern - Fault Tolerant Microservices¶
操作系统:设计与实现¶
2024 南京大学《操作系统:设计与实现》。课程主页(含讲义):https://jyywiki.cn/OS/2024/。
每个程序员都应该了解的¶
数据结构与算法¶
数据结构¶
【中英字幕】油管百万播放的《数据结构》教程,Google顶尖程序员亲授,4小时快速入门,完全深度详细讲解!!!
Data Structures Easy to Advanced Course - Full Tutorial from a Google Engineer¶
Learn and master the most common data structures in this full course from Google engineer William Fiset. This course teaches data structures to beginners using high quality animations to represent the data structures visually.
You will learn how to code various data structures together with simple to follow step-by-step instructions. Every data structure presented will be accompanied by some working source code (in Java) to solidify your understanding.
💻 Code: https://github.com/williamfiset/Algorithms
Data Structures - Full Course Using C and C++¶
Learn about data structures in this comprehensive course. We will be implementing these data structures in C or C++.
Data structures¶
In this series of lessons, we will study and implement data structures. We will be implementing these data structures in c or c++.
Data Structures¶
Rob teaches CS310, Data Structures in Java at San Diego State University. These lectures accompany that course. There are both discussions of the topics and demonstrations about how to write the code.
C/C++¶
Pointers in C / C++ Full Course¶
Pointers in C and C++ are often challenging to understand. In this course, they will be demystified, allowing you to use pointers more effectively in your code. The concepts you learn in this course apply to both C and C++.
Go¶
后端大师班 - Go(Backend master class [Golang, Postgres, Docker])¶
https://www.youtube.com/watch?v=rx6CPDK_5mU&list=PLy_6D98if3ULEtXtNSY_2qN21VCKgo
在这个后端大师班中,我们将学习有关如何使用PostgreSQL,Golang和Docker从头开始设计,开发和部署完整的后端系统的所有知识。注意:-我们将每周继续将新的讲座上传到课程的播放列表。
Github地址: https://github.com/techschool/simplebank
【幼麟实验室】Golang合辑¶
Ended: 视频
PPT ↵
Ended: PPT
Ended: Jupyter/视频/PPT
QA ↵
redis¶
redis为什么高效?¶
- 纯内存缓存数据库
- 使用单线程,避免了多线程带来的频繁的上下文切换
- 使用了高效的数据结构体
- 客户端和服务端使用了非阻塞的IO多路复用模型
redis支持哪些数据结构?¶
- 字符串 string
- 列表 list
- 哈希表 hash
- 集合 set
- 有序集合 Sorted Set
高级数据结构有:HyperLogLog/BitMap/BloomFilter/GeoHash
redis典型使用场景有哪些?¶
-
队列
-
使用lpush/brpop或者blpop/rpush
优点:使用简单, 缺点:若消费过慢,容易出现热点数据,不支持消费确认,不支持重复消费和多次消费 2. 使用SUBSCRIBE/PUBLISH 发布订阅模式
优点:使用简单,支持多次消费。缺点:不支持持久化,容易丢数据。若消息者异常,则在异常这段时间,消息是无法被该消费者消费到的。
-
使用有序集合(Sorted-Set)实现延迟队列
-
使用Stream类型
优点:redis5.0支持,近乎完美。1.支持阻塞或非阻塞式读取 2. 支持消费组模式,从而支持多次消费 2. 支持消息队列监控
-
-
排行榜
-
自动补全
-
分布式锁
为了避免单点故障,可以使用redlock。
redlock原理:不能只在一个redis实例上创建锁,应该是在多个redis实例上创建锁,n/2 + 1,必须在大多数redis节点上都成功创建锁,才能算这个整体的RedLock加锁成功,避免说仅仅在一个redis实例上加锁而带来的问题。
-
UV统计
使用HyperLogLog数据结构实现:
-
去重过滤
基于bitmap实现布隆过滤器或者直接使用Redis Module: BloomFilter
-
限流器
-
用户签到/用户在线状态/活跃用户统计等统计功能
基于位图bitmap实现
redis中key有哪些过期策略?¶
-
惰性删除策略
当访问key时候,再检查key是否过期
-
定期删除策略
Redis 会将每个设置了过期时间的 key 放入到一个独立的字典中,默认每 100ms 进行一次过期扫描:
-
从过期key字典中随机 20 个 key,删除这 20 个 key 中已经过期的 key
-
如果过期的 key 比率超过 ¼,那就重复步骤 1
-
扫描处理时间默认不会超过 25ms
-
redis中最大内存策略有哪些?¶
redis中最大内存是由maxmemory参数配置的,当内存使用达到设置的最大内存上限时候,会执行最大内存策略:
-
noeviction 从不驱逐,一直写,直到可用内存使用完,写不进数据
-
volatile
-
volatile-ttl 设置了过期时间,ttl时间越短的key越优先被淘汰
-
volatile-lru 基于LRU规则(Least Recently Used)淘汰删除设置了过期时间的key
-
volatile-random 随机淘汰过期集合中的key
-
-
allkeys
-
allkeys-lru 基于LRU规则淘汰所有key
-
allkeys-random 随机淘汰
-
redis中持久化方式有哪些?¶
RDB¶
RDB是Snapshot快照存储,是redis默认的持久化方式,即按照一定的策略周期性的将数据保存到磁盘上。配置文件中的save参数来定义快照的周期。
# RDB持久化策略 默认三种方式,[900秒内有1次修改],[300秒内有10次修改],[60秒内有10000次修改]即触发RDB持久化,我们可以手动修改该参数或新增策略
save 900 1
save 300 10
save 60 10000
优点:
- 压缩的二进制文件,非常适合备份和灾难恢复
缺点:
- 备份操作需要fork一个进程(使用bgsave命令)属于重操作
- 不能最大限度避免丢失数据
AOF¶
AOF(Append-Only File),Redis会将每一个收到的写命令都通过Write函数追加到文件中(默认appendonly.aof)。
优点:
- 以文本形式保存,易读
- 能最大限度避免丢失数据
缺点:
- 文件体积过大(可以使用bgrewriteaof重写aof)
- 相比rdb,aof恢复数据较慢
AOF支持三种同步策略:
-
always
每条Redis写命令都同步写入硬盘
-
everysec
每秒执行一次同步,将多个命令写入硬盘
-
no
由操作系统决定何时同步。
Redis重启的时候优先加载AOF文件,如果AOF文件不存在再去加载RDB文件。 如果AOF文件和RDB文件都不存在,那么直接启动。 不论加载AOF文件还是RDB文件,只要发生错误都会打印错误信息,并且启动失败。
redis键的数据结构是什么样的?¶
整个Redis 数据库的所有key 和value 也组成了一个全局字典,还有带过期时间的key 集合也是一个字典。
struct RedisDb {
dict * dict; /* all keys key=>value */
dict * expires; /* all expired keys key=>long(timestamp) */
...
}
zset底层数据结构是什么样的?¶
Redis 的zset 是一个复合结构,一方面它需要一个hash结构(字典)来存储value(成员) 和score 的对应关系, 另一方面需要提供按照score 来排序的功能,还需要能够指定score 的范围来获取value 列表的功能,这个时候通过跳跃列表实现。
typedef struct zset {
dict *dict; // 字典
zskiplist *zsl; // 跳表
} zset;
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length;
int level;
} zskiplist;
typedef struct zskiplistNode {
sds ele; // 节点存储的具体值
double score; // 节点对应的分值
struct zskiplistNode *backward; // 后退指针
struct zskiplistLevel {
struct zskiplistNode *forward; // //前进指针
unsigned long span; // 到下一个节点的跨度
} level[];
} zskiplistNode;
set底层数据结构是什么样的?¶
set底层数据结构有两种,即intset和dict。当满足以下条件时候,使用intset,否则使用dict:
- 结合对象保存的所有元素都是整数值
- 集合对象保存的元素数量不超过512个
当使用dict时候,即hashTable,哈希表的key值是set集合元素,value值是nil。
资料:https://juejin.cn/post/6844904198019137550
Zrank的复杂度是O(log(n)),为什么?¶
Redis 在skiplist 的forward 指针上进行了优化,给每一个forward 指针都增加了span 属性,span 是「跨度」的意思, 表示从前一个节点沿着当前层的forward 指针跳到当前这个节点中间会跳过多少个节点 这样计算一个元素的排名时,只需要将「搜索路径」上的经过的所有节点的跨度span 值进行叠加就可以算出元素的最终rank 值。
zrange 的复杂度是 O(log(N)+M), N 为有序集的基数,而 M 为结果集的基数。为什么是这个复杂度呢?¶
ZRANGE key start stop [WITHSCORES],zrange 就是返回有序集 key 中,指定区间内的成员,而跳表中的元素最下面的一层是有序的(上面的几层就是跳表的索引),按照分数排序,我们只要找出 start 代表的元素,然后向前或者向后遍历 M 次拉出所有数据即可,而找出 start 代表的元素,其实就是在跳表中找一个元素的时间复杂度。跳表中每个节点每一层都会保存到下一个节点的跨度,在寻找过程中可以根据跨度和来求当前的排名,所以查找过程是 O(log(N) 过程,加上遍历 M 个元素,就是 O(log(N)+M),所以 redis 的 zrange 不会像 mysql 的 offset 有比较严重的性能问题。
redis中哪些操作时间复杂度O(n)?¶
Key¶
List¶
Hash¶
Set¶
smembers // 返回所有集合成员,n为集合成员元素
sunion/sunionstore // 并集, N 是所有给定集合的成员数量之和
sinter/sinterstore // 交集,O(N * M), N 为给定集合当中基数最小的集合, M 为给定集合的个数
sdiff/sdiffstore // 差集, N 是所有给定集合的成员数量之和
Sorted Set:
zrange/zrevrange/zrangebyscore/zrevrangebyscore/zremrangebyrank/zremrangebyscore // O(m) + O(log(n)) // N 为有序集的基数,而 M 为结果集的基数
redis哨兵模式介绍?¶
redis的哨兵模式(sentinel)为了保证redis主从的高可用。主要功能:
- 监控:它会监听主服务器和从服务器之间是否在正常工作。
- 故障转移:它在主节点出了问题的情况下,会在所有的从节点中竞选出一个节点,并将其作为新的主节点。
从从节点选出一个主节点流程是: 1. 首先去掉所有断线或下线的节点,获取所有监控节点 2. 然后选择**复制偏移量**最大的节点,复制偏移量代表其从主节点成功复制了多少数据,越大说明越与主节点最接近同步。 3. 若步骤选出了多个节点,那比较每个节点的唯一标识uid,选择最小的那个。
将从节点变成主节点操作时候,需要哨兵来操作,由于为了保证哨兵高可用性,哨兵存在多个,那就需要选主出来一个哨兵头领来处理这个操作,这个过程涉及到Raft算法。
redis cluster分片原理?¶
采用虚拟槽进行数据分片,总共214个虚拟槽,有几个节点就把214分成几个范围,按照crc16(key)%2^14确定key在哪个槽,每个节点保存了集群中的槽对应的节点信息,如果一个请求过来发现key不在这个节点上,这个节点会回复一个mov的消息指向新节点,彼此节点间定时通过ping来检测故障。
redis rehash过程?¶
redis采用渐进式hash方式。redis会同时维持两个hash表:ht[0] 和 ht[1] 两个哈希表。 要在字典里面查找一个键的话, 程序会先在 ht[0] 里面进行查找, 如果没找到的话, 就会继续到 ht[1] 里面进行查找, 诸如此类。在渐进式 rehash 执行期间, 新添加到字典的键值对一律会被保存到 ht[1] 里面, 而 ht[0] 则不再进行任何添加操作
redis底层数据结构?¶
mysql¶
mysql存储引擎Myisam和innodb区别?¶
-
InnoDB支持事务,MyISAM不支持
-
InnoDB支持外键,而MyISAM不支持
-
InoDB支持行级别锁,有更高的并发性,而MyISAM只支持表级别锁
-
InnoDB是聚集索引,数据文件是和索引绑在一起的,必须要有主键,通过主键索引效率很高
mysql是ACID是如何保证的?¶
mysql事务特征有:
-
原子性(Atomicity): 事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用;
-
一致性(Consistency): 执行事务前后,数据保持一致;
-
隔离性(Isolation): 并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的;
-
持久性(Durability): 一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库 发生故障也不应该对其有任何影响。
保证措施:
- A 原子性由undo log日志保证,它记录了需要回滚的日志信息,事务回滚时撤销已经执行成功的sql
- C 一致性一般由代码层面来保证
- I 隔离性由MVCC来保证
- D 持久性由内存+redo log来保证,mysql修改数据同时在内存和redo log记录这次操作,事务提交的时候通过redo log刷盘,宕机的时候可以从redo log恢复。
mysql事务隔离级别有哪些?¶
mysql支持四种隔离级别,默认是可重复读的 (REPEATABLE READ)。
| 隔离级别 | 脏读 | 不可重复读取 | 幻影数据行 |
|---|---|---|---|
| READ UNCOMMITTED(RU) - 读未提交 | X | X | X |
| READ COMMITTED(RC) - 读已提交 | √ | X | X |
| REPEATABLE READ(RR) - 可重复读 | √ | √ | X |
| SERIALIZABLE(SZ) - 串行化 | √ | √ | √ |
不可重复读与幻读的区别是?
- 不可重复读的重点是修改:同样的条件, 你读取过的数据, 再次读取出来发现值不一样了,其只需要锁住满足条件的记录
- 幻读的重点在于新增或者删除:同样的条件, 第1次和第2次读出来的记录数不一样,要锁住满足条件及其相近的记录
mysql在可重复读模式下,如何解决不可重复读的问题?¶
mvcc+undo log解决了快照读可能会导致的不可重复读的问题。Mysql默认隔离级别是RR(可重复读),是通过“行锁+MVCC”来实现的,正常读时不加锁,写时加锁,MVCC的实现依赖于:三个隐藏字段,Read View、Undo log 来实现。
MVCC的目的就是多版本并发控制,在数据库中的实现,就是为了解决读写冲突,它的实现原理主要是依赖记录中的 3个隐式字段,undo log ,Read View 来实现的。 InnoDB MVCC的实现基于undo log,通过回滚指针来构建需要的版本记录。通过ReadView来判断哪些版本的数据可见。同时Purge线程是通过ReadView来清理旧版本数据。
mysql的当前读和快照读是什么回事?¶
快照读¶
MVCC就是为了实现读-写冲突不加锁,而这个读指的就是快照读, 而非当前读,当前读实际上是一种加锁的操作,是悲观锁的实现。这个快照读的实现方式就是多版本并发控制MVCC。
快照读(snapshot read)能看到别的事务生成快照前提交的数据,而不能看到别的事务生成快照后提交的数据或者未提交的数据。快照读是repeatable-read 和 read-committed 级别下,默认的查询模式,好处是:读不加锁,读写不冲突,这个对于 MySQL 并发访问提升很大。
使用快照读的场景:
-
单纯的select操作,不包括上述 select … lock in share mode、select … for update
-
Read Committed隔离级别下快照读:每次select都生成一个快照读
-
Read Repeatable隔离级别下快照读:开启事务后第一个select语句才是快照读的地方,而不是一开启事务就快照读
当前读¶
当前读读取的是最新版本, 并且对读取的记录加锁,保证其他事务不会再并发的修改这条记录,避免出现安全问题。
使用当前读的场景:
-
select…lock in share mode (共享读锁)
-
select…for update
-
update
-
delete
-
insert
当前读的实现方式:next-key锁(行记录锁+Gap间隙锁)
快照读和当前读实验:
MVCC¶
未完成事务,也称为活跃事务。
- m_ids: 在生成ReadView时,当前活跃的-读写事务的事务id列表,包含当前事务版本
- min_trx_id: m_ids的最小值
- max_trx_id: m_ids的最大值+1,即下一个要生成的事务id
- creator_trx_id: 生成该事务的事务id
版本链中的当前版本是否可以被当前事务可见的要根据这四个属性按照以下几种情况来判断
- 当 trx_id = creator_trx_id 时:当前事务可以看见自己所修改的数据, 可见,
- 当 trx_id < min_trx_id 时 : 生成此数据的事务已经在生成readView前提交了, 可见
- 当 trx_id >= max_trx_id 时 :表明生成该数据的事务是在生成ReadView后才开启的, 不可见
- 当 min_trx_id <= trx_id < max_trx_id 时
- trx_id 在 m_ids 列表里面 :生成ReadView时,活跃事务还未提交,不可见
- trx_id 不在 m_ids 列表里面 :事务在生成readView前已经提交了,可见。(RC隔离级别会出现这种情况)
innodb三种行锁算法¶
InnoDB有三种行锁的算法:
-
Record Lock:单个行记录上的锁。
-
Gap Lock:间隙锁,锁定一个范围,但不包括记录本身。GAP锁的目的,是为了防止同一事务的两次当前读,出现幻读的情况。
-
Next-Key Lock:1+2,锁定一个范围,并且锁定记录本身。对于行的查询,都是采用该方法,主要目的是解决幻读的问题。
truncate,delete,drop区别?¶
- truncate 会删除所有数据
- delete 可以删除部分数据,会触发触发器
- drop会删除整个表和数据
mysql索引分类¶
从物理存储角度¶
- 聚簇索引
InnoDB中主键索引是聚集索引,索引跟数据在一起的。其他索引是非聚集索引,索引指向的是主键索引。为MyISAM存储引擎数据文件和索引文件是分离的,不存在聚集索引的概念。
- 非聚簇索引
从数据结构角度¶
-
B+树索引
-
hash索引
基于哈希表实现,只有全值匹配才有效
-
全文索引
查找的是文本中的关键词,而不是直接比较索引中的值,类似于搜索引擎做的事情。
从逻辑角度¶
-
唯一索引,唯一索引也是一种约束。唯一索引的属性列不能出现重复的数据,但是**允许数据为NULL**,一张表允许创建多个唯一索引。UNIQUE ( column )
-
主键索引,数据表的主键列使用的就是主键索引PRIMARY KEY ( column)
-
联合索引,指多个字段上创建的索引 INDEX index_name ( column1, ... ),使用时最左匹配原则
-
普通 /单列索引,普通索引的唯一作用就是为了快速查询数据,一张表允许创建多个普通索引,并允许数据重复和NULL。INDEX index_name ( column )。
-
全文索引,查找的是文本中的关键词,而不是直接比较索引中的值,类似于搜索引擎做的事情。FULLTEXT ( column)
资料:https://juejin.cn/post/6907966385394515975
mysql为什么使用b+树?¶
B+树是一个平衡的多叉树,B+树中的B不是代表二叉(binary),而是代表平衡(balance)。
B+树和B树区别:
-
B +树中的非叶子节点不存储数据,并且存储在叶节点中的所有数据使得查询时间复杂度固定为log n。
-
B树查询时间的复杂度不是固定的,它与键在树中的位置有关,最好是O(1)。
-
由于B+树的叶子节点是通过双向链表链接的,所以支持范围查询,且效率比B树高
-
B树每个节点的键和数据是一起的
哈希索引的优势与劣势?¶
优点:
- 等值查询,哈希索引具有绝对优势,时间复杂度为O(1)
缺点:
-
不支持范围查询
-
不支持索引完成排序
-
不支持联合索引的最左前缀匹配规则
为什么uuid不适合做innodb主键?¶
mysql调优几种方式¶
- 打开慢查询日志,查看慢查询
- explain看一下执行计划,查看①key字段是否使用到索引,使用到什么索引。②type字段是否为ALL全表扫描。③row字段扫描的行数是否过大,估计值。MySQL数据单位都是页,使用采样统计方法。④extra字段是否需要额外排序,就是不能通过索引顺序达到排序效果;是否需要使用临时表等。⑤如果是组合索引的话通过key_len字段判断是否被完全使用。
- 使用覆盖索引。
- 注意最左前缀原则
- 使用前缀索引。当要给字符串加索引时,可以使用前缀索引,节省资源占用。如果前缀区分度不高可以倒序存储或者是存储hash。
- 索引下推
- 注意隐式类型转换,防止索引实现
- 区分度不大的字段避免使用索引,比如性别字段
mysql语句性能评测?¶
使用explain分析语句执行,主要看select_type、type、key、possiable keys、extra列。
查询类型 - select_type¶
-
SIMPLE : 简单的select查询,查询中不包含子查询或者UNION
-
PRIMARY: 查询中若包含复杂的子查询,最外层的查询则标记为PRIMARY
-
SUBQUERY : 在SELECT或者WHERE列表中包含子查询
-
DERIVED : 在from列表中包含子查询被标记为DRIVED衍生,MYSQL会递归执行这些子查询,把结果放到临时表中
-
UNION: 若第二个SELECT出现在union之后,则被标记为UNION, 若union包含在from子句的子查询中,外层select被标记为:derived
-
UNION RESULT: 从union表获取结果的select
连接类型 - type¶
有如下几种取值,性能从好到坏排序 如下:
-
const
针对主键或唯一索引的等值查询扫描, 最多只返回一行数据. const 查询速度非常快, 因为它仅仅读取一次即可
-
ref
当满足索引的**最左前缀规则**,或者索引不是主键也不是唯一索引时才会发生。如果使用的索引只会匹配到少量的行,性能也是不错的
-
range
范围扫描,表示检索了指定范围的行,主要用于**有限制的索引扫描**。比较常见的范围扫描是带有BETWEEN子句或WHERE子句里有>、>=、<、<=、IS NULL、<=>、BETWEEN、LIKE、IN()等操作符。
-
index
全索引扫描,和ALL类似,只不过index是全盘扫描了索引的数据。当查询仅使用索引中的一部分列时,可使用此类型。有两种场景会触发: 如果索引是查询的覆盖索引,并且索引查询的数据就可以满足查询中所需的所有数据,则只扫描索引树。此时,explain的Extra 列的结果是Using index。index通常比ALL快,因为索引的大小通常小于表数据。
-
ALL
全表扫描,性能最差
key¶
表示MySQL实际选择的索引
possiable keys¶
MYSQL可能用到的key
rows¶
MySQL估算会扫描的行数,数值越小越好。
mysql主从复制流程?¶
mysql slave节点连接master节点时,master节点会新建一个binlog dump线程,当master数据有更新时写入到binlog,dump线程通知slave的io线程接收后写入到本地的relaylog,然后通过sql线程重放
mysql日志类型¶
mysql日志主要包括错误日志、查询日志、慢查询日志、事务日志、二进制日志几大类。
binlog¶
binlog 用于记录数据库执行的写入性操作(不包括查询)信息,以二进制的形式保存在磁盘中。binlog 的主要使用场景有两个,分别是 主从复制 和 数据恢复。
-
主从复制 :在 Master 端开启 binlog ,然后将 binlog发送到各个 Slave 端, Slave 端重放 binlog 从而达到主从数据一致。
-
数据恢复 :通过使用 mysqlbinlog 工具来恢复数据。
redo log¶
当有一条记录要更新时,InnoDB先记录日志再更新内存,然后在比较空闲的时候将操作更新到磁盘,有了redolog即使MySQL崩溃也不会丢失数据,这个能力称为crash-safe。redo log是事务持久性的保证。
Undo log¶
undo log用于回滚操作,保证事务的原子性。
slow log¶
slow log用来记录慢查询
查询语句不同元素(where、jion、limit、group by、having等等)执行先后顺序?¶
where在聚合前先筛选记录,也就是说作用在group by和having之前。而 having子句在聚合后对组记录进行筛选。
Innodb为什么一定需要一个主键,且必须自增列作为主键?¶
如果我们定义了主键(PRIMARY KEY),那么InnoDB会选择主键作为聚集索引。如果没有显式定义主键,则InnoDB会选择第一个不包含有NULL值的唯一索引作为主键索引。如果也没有这样的唯一索引,则InnoDB会选择内置6字节长的ROWID作为隐含的聚集索引(ROWID随着行记录的写入而主键递增,这个ROWID不像ORACLE的ROWID那样可引用,是隐含的)。
总之Innodb一定需要一个主键。
- 这是因为数据记录本身被存于主索引(一颗B+Tree)的叶子节点上,这就要求同一个叶子节点内(大小为一个内存页或磁盘页)的各条数据记录按主键顺序存放
- 如果表使用自增主键,那么每次插入新的记录,记录就会顺序添加到当前索引节点的后续位置,当一页写满,就会自动开辟一个新的页(这样的页称为叶子页)
- 如果使用非自增主键(如果身份证号或学号等),由于每次插入主键的值近似于随机,因此每次新记录都要被插到现有索引页得中间某个位置
在MVCC并发控制中,读操作可以分成哪两类?¶
快照读 (snapshot read):读取的是记录的可见版本 (有可能是历史版本),不用加锁(共享读锁s锁也不加,所以不会阻塞其他事务的写)。
当前读 (current read):读取的是记录的最新版本,并且,当前读返回的记录,都会加上锁,保证其他事务不会再并发修改这条记录。
Mysql中DATATIME和TIMESTAP的区别?¶
datetime、timestamp精确度都是秒,datetime与时区无关,存储的范围广(1001-9999),占空间8个字节,timestamp与时区有关,查询时候会转换成相应时区显示,存储的范围小(1970-2038),占用空间4个字节。
MySQL是如何解决幻读的?¶
- 事务隔离级别设置为SERIALIZABLE 串行化
-
MVCC + Next-Key Lock
Next-Key Lock(临键锁) 是Gap Lock(间隙锁)和Record Lock(记录锁,属于行锁)的结合版,都属于Innodb的锁机制。 比如:select * from tb where id>100 for update:
- 主键索引 id 会给 id=100 的记录加上 record行锁
-
索引 id 上会加上 gap 锁,锁住 id(100,+无穷大)这个范围,其他事务对 id>100 范围的记录读和写操作都将被阻塞。插入 id=1000的记录时候会命中索引上加的锁会报出事务异常;
-
Next-Key Lock会确定一段范围,然后对这个范围加锁,保证A在where的条件下读到的数据是一致的,因为在where这个范围其他事务根本插不了也删不了数据,都被Next-Key Lock锁堵在一边阻塞掉了。
记录锁是行级别的锁(row-level locks),当InnoDB 对索引进行搜索或扫描时,会在索引对应的记录上设置共享或排他的记录锁。
Mysql什么时候会取得gap lock或nextkey lock?¶
- 只在REPEATABLE READ或以上的隔离级别下的特定操作才会有可能取得gap lock或nextkey lock
-
locking reads(SELECT with FOR UPDATE or LOCK IN SHARE MODE),UPDATE和DELETE时,除了对唯一索引的唯一搜索外都会获取gap锁或next-key锁。即锁住其扫描的范围。
For locking reads (SELECT with FOR UPDATE or LOCK IN SHARE MODE), UPDATE, and DELETE statements, locking depends on whether the statement uses a unique index with a unique search condition, or a range-type search condition. For a unique index with a unique search condition, InnoDB locks only the index record found, not the gap before it. For other search conditions, InnoDB locks the index range scanned, using gap locks or next-key locks to block insertions by other sessions into the gaps covered by the range.
http://dev.mysql.com/doc/refman/5.7/en/innodb-transaction-isolation-levels.html
Mysql为什么要进行分库分表?¶
-
数据量
MySQL单库数据量在5000万以内性能比较好,超过阈值后性能会随着数据量的增大而变弱。MySQL单表的数据量是500w-1000w之间性能比较好,超过1000w性能也会下降。
-
磁盘
因为单个服务的磁盘空间是有限制的,如果并发压力下,所有的请求都访问同一个节点,肯定会对磁盘IO造成非常大的影响。
-
数据库连接
数据库连接是非常稀少的资源,如果一个库里既有用户、商品、订单相关的数据,当海量用户同时操作时,数据库连接就很可能成为瓶颈。
Mysql什么时候使用分区?¶
- 查询速度慢,数据量大
- 对数据的操作往往只涉及一部分数据,而不是所有的数据
Mysql分区类型有哪些?¶
Mysql分区后的一个优点是:涉及到 SUM()/COUNT() 等聚合函数时,可以并行进行。
MySQL支持范围分区(RANGE),列表分区(LIST),哈希分区(HASH)以及KEY分区四种。
分区字段不能为NULL,要不然怎么确定分区范围,所以尽量NOT NULL。
范围分区¶
基于属于一个给定连续区间的列值,把多行分配给分区。这些区间要连续且不能相互重叠,使用VALUES LESS THAN操作符来进行定义。
CREATE TABLE employees (
id INT NOT NULL,
fname VARCHAR(30),
lname VARCHAR(30),
hired DATE NOT NULL DEFAULT '1970-01-01',
separated DATE NOT NULL DEFAULT '9999-12-31',
job_code INT NOT NULL,
store_id INT NOT NULL
)
partition BY RANGE (store_id) (
partition p0 VALUES LESS THAN (6),
partition p1 VALUES LESS THAN (11),
partition p2 VALUES LESS THAN (16),
partition p3 VALUES LESS THAN (21),
PARTITION p4 VALUES LESS THAN MAXVALUE # MAXVALUE是最大值,防止store_id大于21时候,找不到分区写入失败
);
LIST分区¶
类似于按RANGE分区,区别在于LIST分区是基于列值匹配一个离散值集合中的某个值来进行选择.
假定有20个音像店,分布在4个有经销权的地区,如下表所示:
| 地区 | 商店ID 号 |
|---|---|
| 北区 | 3, 5, 6, 9, 17 |
| 东区 | 1, 2, 10, 11, 19, 20 |
| 西区 | 4, 12, 13, 14, 18 |
| 中心区 | 7, 8, 15, 16 |
要按照属于同一个地区商店的行保存在同一个分区中的方式来分割表,可以使用下面的“CREATE TABLE”语句:
CREATE TABLE employees (
id INT NOT NULL,
fname VARCHAR(30),
lname VARCHAR(30),
hired DATE NOT NULL DEFAULT '1970-01-01',
separated DATE NOT NULL DEFAULT '9999-12-31',
job_code INT,
store_id INT
)
PARTITION BY LIST(store_id)
PARTITION pNorth VALUES IN (3,5,6,9,17),
PARTITION pEast VALUES IN (1,2,10,11,19,20),
PARTITION pWest VALUES IN (4,12,13,14,18),
PARTITION pCentral VALUES IN (7,8,15,16)
);
需要注意的是如果试图插入列值(或分区表达式的返回值)不在分区值列表中的一行时,那么“INSERT”查询将失败并报错。
HASH分区¶
基于用户定义的表达式的返回值来进行选择的分区,该表达式使用将要插入到表中的这些行的列值进行计算。
CREATE TABLE employees (
id INT NOT NULL,
fname VARCHAR(30),
lname VARCHAR(30),
hired DATE NOT NULL DEFAULT '1970-01-01',
separated DATE NOT NULL DEFAULT '9999-12-31',
job_code INT,
store_id INT
)
PARTITION BY HASH(store_id)
PARTITIONS 4; # 一定要指定分区数
Hash分区的字段必须是整数类型,也可以基于用户定义的表达式的返回值进行分布区,但返回值必须是整数类型,比如:partition by hash (YEAR(b))。
KEY分区¶
KEY分区跟HASH分区类似,分区字段可以是除text和BLOB外的所有类型,比如varchar类型。
Mysql分库分表中间件有哪些?¶
分库分表中间件全部可以归结为两大类型:
-
CLIENT模式
阿里的TDDL,开源社区的sharding-jdbc
没有中间层,性能开销低,维护和升级麻烦
-
PROXY模式
MyCat、DBProxy
维护和升级相对简单些,支持集中式监控,缺点是有中间层,有成本开销,中间层必须高可用
Mysql小表驱动大表优化技巧?¶
Join¶
比如:user表10000条数据,class表20条数据
select * from user u left join class c u.userid=c.userid
这样则需要用user表循环10000次才能查询出来,而如果用class表驱动user表则只需要循环20次就能查询出来
但是下面使用小结果集驱动大结果集,结果会更好:
select * from class c left join user u c.userid=u.userid
exist 还是 in¶
select A.name from A where A.id in(select B.id from B)
表B驱动A
select A.name from A where exists(select 1 from B where A.id = B.id)
表A驱动B
索引名字规则¶
idx(a, b, c) HIT where a = x and b = x
idx(a, b, c) HIT where a > x
idx(a, b, c) Not HIT where b > x
idx(a, b, c) Not HIT where a > x and b = x
idx(a, b, c) Not HIT where a = x and c = x
idx(a, b, c) HIT where a = x order by b
idx(a, b, c) HIT where a > x order by a
idx(a, b, c) HIT where a = x and b > x order by a
idx(a, b, c) Not HIT where a > x order by b
idx(a, b, c) HIT where a = x group by a, b
idx(a, b, c) Not HIT where a = x group by b
mysql建立多列索引(联合索引)有最左前缀的原则,即最左优先,如:
- 如果有一个2列的索引(col1,col2),则已经对(col1)、(col1,col2)上建立了索引;
- 如果有一个3列索引(col1,col2,col3),则已经对(col1)、(col1,col2)、(col1,col2,col3)上建立了索引;
最左前缀索引:
mysql会一直向右匹配直到遇到范围查询(>、<、between、like)就停止匹配,比如a = 1 and b = 2 and c > 3 and d = 4 如果建立(a,b,c,d)顺序的索引,d是用不到索引的,如果建立(a,b,d,c)的索引则都可以用到,a,b,d的顺序可以任意调整。
如where a>10 order by b ,索引a_b 无法排序 =和in可以乱序,比如a = 1 and b = 2 and c = 3 建立(a,b,c)索引可以任意顺序,mysql的查询优化器会帮你优化成索引可以识别的形式
b+ 树的数据项是复合的数据结构,比如 (name,age,sex) 的时候,b+ 树是按照从左到右的顺序来建立搜索树的,比如当 (张三,20,F) 这样的数据来检索的时候,b+ 树会优先比较 name 来确定下一步的所搜方向,如果 name 相同再依次比较 age 和 sex,最后得到检索的数据;但当 (20,F) 这样的没有 name 的数据来的时候,b+ 树就不知道第一步该查哪个节点,因为建立搜索树的时候 name 就是第一个比较因子,必须要先根据 name 来搜索才能知道下一步去哪里查询
select xx for update where id = x操作会加写锁吗?¶
属于当前读,可能是行记录锁,也有可能是间隙锁。但都是加的写锁。写锁,意味着对其他客户端读写都会加锁,需要注意的是,并不是所有的读会加锁,它只对于其他客户端的当前读(select * for update, update, delete等)会加锁,普通的select不加锁的。
mysql中悲观锁、乐观锁、共享锁、排他锁有什么区别?¶
更多资料¶
TCP¶
什么是 TCP ?¶
TCP 是面向连接的、可靠的、基于字节流的传输层通信协议。
TCP三次握手流程?¶
开始客户端和服务器都处于CLOSED状态,然后服务端开始监听某个端口,进入LISTEN状态
- 第一次握手(SYN=1, seq=x),发送完毕后,客户端进入 SYN_SEND 状态
- 第二次握手(SYN=1, ACK=1, seq=y, ACKnum=x+1), 发送完毕后,服务器端进入 SYN_RCVD 状态。
- 第三次握手(ACK=1,ACKnum=y+1),发送完毕后,客户端进入 ESTABLISHED 状态,当服务器端接收到这个包时,也进入 ESTABLISHED 状态,TCP 握手,即可以开始数据传输。
当TCP连接一个不存在的端口时候,会不会有三次握手过程?¶
不会有。因为当服务器收到没有监听端口的连接请求时会返回RST包。
TCP四次挥手过程?¶
- 第一次挥手(FIN=1,seq=u),发送完毕后,客户端进入FIN_WAIT_1 状态
- 第二次挥手(ACK=1,ack=u+1,seq=v),发送完毕后,服务器端进入CLOSE_WAIT 状态,客户端接收到这个确认包之后,进入 FIN_WAIT_2 状态
- 第三次挥手(FIN=1,ACK1,seq=w,ack=u+1),发送完毕后,服务器端进入LAST_ACK 状态,等待来自客户端的最后一个ACK。
- 第四次挥手(ACK=1,seq=u+1,ack=w+1),客户端接收到来自服务器端的关闭请求,发送一个确认包,并进入 TIME_WAIT状态,等待了某个固定时间(两个最大段生命周期,2MSL,2 Maximum Segment Lifetime)之后,没有收到服务器端的 ACK ,认为服务器端已经正常关闭连接,于是自己也关闭连接,进入 CLOSED 状态。服务器端接收到这个确认包之后,关闭连接,进入 CLOSED 状态。
TCP挥手为什么需要四次?¶
数据传输是双向传输的,一方告诉对方数据传输完成,需要两次挥手:一次发送通知给对方说我已经传输完成,一次需要接收到对方确认收到通知。也可以这么说每个方向都需要一个 FIN 和一个 ACK,因此通常被称为四次挥手。
TIME-WAIT 状态为什么需要等待 2MSL?¶
MSL 是 Maximum Segment Lifetime,报文最大生存时间,它是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。
主动发起关闭连接的一方,才会有 TIME-WAIT 状态。
- 1个 MSL 保证四次挥手中主动关闭方最后的 ACK 报文能最终到达对端
- 1个 MSL 保证对端没有收到 ACK 那么进行重传的 FIN 报文能够到达
TIME_WAIT 过多有什么危害?¶
如果服务器有处于 TIME-WAIT 状态的 TCP,则说明是由服务器方主动发起的断开请求。过多的 TIME-WAIT 状态主要的危害有两种:
- 第一是内存资源占用;
- 第二是对端口资源的占用,一个 TCP 连接至少消耗一个本地端口,导致无法创建链接
端口资源有限,一般可以开启的端口为 32768~61000,也可以通过如下参数设置指定
net.ipv4.ip_local_port_range
如何优化 TIME_WAIT?¶
- 复用处于 TIME_WAIT 的 socket 为新的连接所用
net.ipv4.tcp_tw_reuse = 1
如何唯一确定一个 TCP 连接呢?¶
- 源地址
- 源端口
- 目的地址
- 目的端口
源地址和目的地址的字段(32位)是在 IP 头部中,作用是通过 IP 协议发送报文给对方主机。源端口和目的端口的字段(16位)是在 TCP 头部中,作用是告诉 TCP 协议应该把报文发给哪个进程。
有一个 IP 的服务器监听了一个端口,它的 TCP 的最大连接数是多少?¶
服务端最大并发 TCP 连接数远不能达到理论上限:
- 首先主要是文件描述符限制,Socket 都是文件,所以首先要通过 ulimit 配置文件描述符的数目;
- 另一个是内存限制,每个 TCP 连接都要占用一定内存,操作系统是有限的。
TCP 和 UDP 的区别有哪些?¶
- TCP面向连接((如打电话要先拨号建立连接);UDP是无连接的,即发送数据之前不需要建立连接。
- TCP要求安全性,提供可靠的服务,通过TCP连接传送的数据,不丢失、不重复、安全可靠。而UDP尽最大努力交付,即不保证可靠交付。
- TCP是点对点连接的,UDP一对一,一对多,多对多都可以
- TCP传输效率相对较低,而UDP传输效率高,它适用于对高速传输和实时性有较高的通信或广播通信。
- TCP适合用于网页,邮件等;UDP适合用于视频,语音广播等
- TCP面向字节流,UDP面向报文
TCP 是如何保证可靠性的?¶
- 连接的可靠性:TCP的连接是基于三次握手,而断开则是四次挥手。确保连接和断开的可靠性
- 数据传输的可靠性和可控性:支持报文校验、报文确认应答、超时重传、流量控制(滑动窗口)
超时重传机制是什么样的?¶
超时重传指的是**在发送数据报文时,设定一个定时器,每间隔一段时间,没有收到对方的ACK确认应答报文,就会重发该报文**。超时重传强调的是客户端。
快速重传机制是什么样的?¶
它基于接收端的反馈信息来引发重传。接收方通过发送三次重复的ACK确认引发客户端快速重传延迟或丢失的报文。
举例子,发送端发送了 1,2,3,4,5,6 份数据:
- 第一份 Seq=1 先送到了,于是就 Ack 回 2;
- 第二份 Seq=2 也送到了,假设也正常,于是ACK 回 3;
- 第三份 Seq=3 由于网络等其他原因,没送到;
- 第四份 Seq=4 也送到了,但是因为Seq3没收到。所以ACK回3;
- 后面的 Seq=4,5的也送到了,但是ACK还是回复3,因为Seq=3没收到。
- 发送端连着收到三个重复冗余ACK=3的确认(实际上是4个,但是前面一个是正常的ACK,后面三个才是重复冗余的),便知道哪个报文段在传输过程中丢失了,于是在定时器过期之前(发送方的超时重传机制),重传该报文段。
- 最后,接收到收到了 Seq3,此时因为 Seq=4,5,6都收到了,于是ACK回7.
但快速重传还可能会有个问题:ACK只向发送端告知最大的有序报文段,到底是哪个报文丢失了呢?并不确定!那到底该重传多少个包呢?是重传 Seq3 呢?还是重传 Seq3、Seq4、Seq5、Seq6 呢?因为发送端并不清楚这三个连续的 ACK3 是谁传回来的。
解决上面问题,有两个办法:
- 带选择确认的重传(SACK)
- D-SACK
TCP 滑动窗口是怎么回事?¶
TCP 发送一个数据,需要收到确认应答,才会发送下一个数据。这样有个缺点,就是效率会比较低。
为了解决这个问题,TCP引入了窗口,它是操作系统开辟的一个缓存空间。窗口大小值表示无需等待确认应答,而可以继续发送数据的最大值。
TCP头部有个字段叫win,也即那个16位的窗口大小,它告诉对方本端的TCP接收缓冲区还能容纳多少字节的数据,这样对方就可以控制发送数据的速度,从而达到流量控制的目的。
TCP 滑动窗口分为两种: 发送窗口和接收窗口。发送端的滑动窗口包含四大部分,如下: - 已发送且已收到ACK确认 - 已发送但未收到ACK确认 - 未发送但可以发送 - 未发送也不可以发送
接收方的滑动窗口包含三大部分,如下:
- 已成功接收并确认
- 未收到数据但可以接收
- 未收到数据并不可以接收的数据
TCP的流量控制是怎么回事?¶
TCP 提供一种机制可以让发送端根据接收端的实际接收能力控制发送的数据量,这就是流量控制。流量控制是作用于接收者的,根据接收端的实际接收能力控制发送速度,防止分组丢失的。
注意TCP的拥塞控制作用于网络的,防止过多的数据包注入到网络中,避免出现网络负载过大的情况。
TCP的粘包和拆包是如何实现的?¶
TCP是面向流,没有界限的一串数据。TCP底层并不了解上层业务数据的具体含义,它会根据TCP缓冲区的实际情况进行包的划分,所以在业务上认为,一个完整的包可能会被TCP拆分成多个包进行发送,也有可能把多个小的包封装成一个大的数据包发送,这就是所谓的TCP粘包和拆包问题。
为什么会产生粘包和拆包呢?
- 要发送的数据小于TCP发送缓冲区的大小,TCP将多次写入缓冲区的数据一次发送出去,将会发生粘包;
- 接收数据端的应用层没有及时读取接收缓冲区中的数据,将发生粘包;
- 要发送的数据大于TCP发送缓冲区剩余空间大小,将会发生拆包;
- 待发送数据大于MSS(最大报文长度),TCP在传输前将进行拆包。即TCP报文长度-TCP头部长度>MSS。
解决方案
- 在数据尾部增加特殊字符进行分割
- 将数据分为两部分,一部分是头部,一部分是内容体;其中头部结构大小固定,且有一个字段声明内容体的大小
半连接队列和 SYN Flood 攻击的关系?¶
一个完整的连接建立过程,服务器会经历 2 种 TCP 状态:SYN_REVD, ESTABELLISHED。对应也会维护两个队列:
- 一个存放SYN的队列(半连接队列,也成SYN队列)
- 一个存放已经完成连接的队列(全连接队列, 也称Accept队列)
SYN Flood是一种典型的DoS (Denial of Service,拒绝服务) 攻击,它在短时间内,伪造不存在的IP地址,向服务器大量发起SYN报文。当服务器回复SYN+ACK报文后,不会收到ACK回应报文,导致服务器上建立大量的半连接半连接队列满了,这就无法处理正常的TCP请求。
解决办法是:
- syn cookie:在收到SYN包后,服务器根据一定的方法,以数据包的源地址、端口等信息为参数计算出一个cookie值作为自己的SYN ACK包的序列号,回复SYN+ACK后,服务器并不立即分配资源进行处理,等收到发送方的ACK包后,重新根据数据包的源地址、端口计算该包中的确认序列号是否正确,如果正确则建立连接,否则丢弃该包。
以太网下,TCP包最大负载是多少?¶
最大负载大小(MSS) = MTU(1500) - IP头大小(20) - TCP头大小(20) = 1460
IP和TCP头大小不固定,最小是20字节
HTTP¶
什么是HTTP协议?¶
超文本传输协议(HTTP)是一种通信协议,它允许将超文本标记语言(HTML)文档从Web服务器传送到客户端的浏览器。目前广泛使用的是HTTP/1.1 版本。
HTTP请求消息和响应消息格式是?¶
请求消息¶
Request 消息分为3部分,第一部分叫Request line, 第二部分叫Request header, 第三部分是body. header和body之间有个空行, 结构如下图
响应消息¶
第一部分叫Response line, 第二部分叫Response header,第三部分是body. header字段之间要有空行(\r\n),header和body之间也有个空行, 结构如下图:
HTTP Cache流程是怎么样的?¶
HTTP Cache有哪些重要的头?¶
Cache-Control¶
选项值有:
- Public :所有内容都将被缓存,在响应头中设置
- Private :内容只缓存到私有服务器中,在响应头中设置
- no-cache :不是不缓存,而是缓存需要校验。
- no-store :所有内容都不会被缓存到缓存或Internet临时文件中,在响应头中设置 must-revalidation/proxy-revalidation :如果缓存的内容失效,请求必须发送到服务器/代理以进行重新验证,在请求头中设置
- max-age=xxx :缓存的内容将在xxx秒后失效,这个选项只在HTTP1.1中可用,和Last-Modified一起使用时优先级较高,在响应头中设置
Expires¶
它通常的使用格式是Expires:Fri ,24 Dec 2027 04:24:07 GMT,后面跟的是日期和时间,超过这个时间后,缓存的内容将失效,浏览器在发出请求之前会先检查这个页面的这个字段,查看页面是否已经过期,过期了就重新向服务器发起请求
Last-Modified / If-Modified-Since¶
它一般用于表示一个服务器上的资源最后的修改时间,资源可以是静态或动态的内容, 通过这个最后修改时间可以判断当前请求的资源是否是最新的。 一般服务端在响应头中返回一个Last-Modified字段,告诉浏览器这个页面的最后修改时间, 浏览器再次请求时会在请求头中增加一个If-Modified-Since字段,询问当前缓存的页面是否是最新的, 如果是最新的就返回304状态码,告诉浏览器是最新的,服务器也不会传输新的数据
Etag/If-None-Match¶
一般用于当Cache-Control:no-cache时,用于验证缓存有效性。
它的作用是让服务端给每个页面分配一个唯一 的编号,然后通过这个编号来区分当前这个页面是否是最新的, 这种方式更加灵活,但是后端如果有多台Web服务器时不太好处理,因为每个Web服务器都要记住网站的所有资源,否则浏览器返回这个编号就没有意义了
HTTP跨域有哪些?¶
CORS跨域访问的请求分三种:
-
simple request
如果一个请求没有包含任何自定义请求头,而且它所使用HTTP动词是GET,HEAD或POST之一,那么它就是一个Simple Request。但是在使用POST作为请求的动词时,该请求的Content-Type需要是application/x-www-form-urlencoded,multipart/form-data或text/plain之一。
-
preflighted request(预请求)
如果一个请求包含了任何自定义请求头,或者它所使用的HTTP动词是GET,HEAD或POST之外的任何一个动词,那么它就是一个Preflighted Request。如果POST请求的Content-Type并不是application/x-www-form-urlencoded,multipart/form-data或text/plain之一,那么其也是Preflighted Request。
-
requests with credential
一般情况下,一个跨域请求不会包含当前页面的用户凭证。一旦一个跨域请求包含了当前页面的用户凭证,那么其就属于Requests with Credential。
对于simple request 只需要在后端程序处理时候设Access-Control-Allow-Orgin头就可以了。
对于preflighted request 每次都会请求2次,第一次options(firefox下看不到这次请求,chrome可以看见)。如果只能跟simple request 一样只设置access-control-allow-orgin是不行的。 还必须处理$_SERVER[‘REQUEST_METHOD’] == ‘OPTIONS’,2者都必须处理
HTTPS的四次握手过程是什么样的?¶
- 客户端发送问候消息,会告诉客户端支持的tls版本,以及支持的加密算法。
- 服务端接收到消息后,会将其服务器证书发给客户端
- 客户端会校验服务器证书是否有授信CA颁发,若是,则会随机生成客户端RSA公私钥,以及会话秘钥。接着客户端会使用服务端加密会话秘钥,并和其公钥一起发送给服务端
- 服务端接着使用服务器私钥获取客户端会话秘钥,并随机生成服务端会话秘钥,并用客户端私钥加密服务端会话秘钥发送给客户端
之后双方通信都会使用对方的会话秘钥进行对称加密通信。
https握手过程分为两步:
通过CA验证服务端的证书是否真实,交换客户端和服务端的对称加密秘钥,以后数据传输,靠这两个进行加密。引入CA目的是为了防止中间人攻击。即攻击者伪造成服务端,然后发送假的证书。
现代浏览器在与服务器建立了一个 TCP 连接后是否会在一个 HTTP 请求完成后断开?什么情况下会断开?¶
默认情况下建立 TCP 连接不会断开,只有在请求报头中声明 Connection: close 才会在请求完成后关闭连接。在 HTTP/1.0 中,一个服务器在发送完一个 HTTP 响应后,会断开 TCP 链接。但是这样每次请求都会重新建立和断开 TCP 连接,代价过大。所以虽然标准中没有设定,某些服务器对 Connection: keep-alive 的 Header 进行了支持。
相比HTTP1,HTTP2有哪些优点?¶
-
多路复用
HTTP/2在一个TCP连接上可以并行的发送多个请求。这是HTTP/2协议最重要的特性,因为这允许你可以异步的从服务器上下载网络资源。许多主流的浏览器都会限制一个服务器的TCP连接数量
-
请求头压缩
HTTP2.0可以维护一个字典,差量更新HTTP头部,大大降低因头部传输产生的流量。HTTP/1.1 的首部带有大量信息,而且每次都要重复发送。HTTP/2.0 要求客户端和服务器同时维护和更新一个包含之前见过的首部字段表,从而避免了重复传输。不仅如此,HTTP/2.0 也使用 Huffman 编码对首部字段进行压缩。
实现方法是: - 维护一份相同的静态字典(Static Table),包含常见的头部名称,以及特别常见的头部名称与值的组合;这个静态字典双方都知道。对于完全匹配的头部键值对,例如 :method: GET,可以直接使用一个字符(字典表的索引id)表示;2)对于头部名称可以匹配的键值对,例如 cookie: xxxxxxx,可以将名称使用一个字符表示。静态字典表见:https://httpwg.org/specs/rfc7541.html#static.table.definition - 维护一份相同的动态字典(Dynamic Table),可以动态地添加内容;客户端发送的就是这份动态字典表。
-
二进制分帧层
TTP/2.0 将报文分成 HEADERS 帧和 DATA 帧,它们都是二进制格式的。
在通信过程中,只会有一个 TCP 连接存在,它承载了任意数量的双向数据流(Stream)。一个数据流都有一个唯一标识符和可选的优先级信息,用于承载双向信息。消息(Message)是与逻辑请求或响应消息对应的完整的一系列帧。帧(Fram)是最小的通信单位,来自不同数据流的帧可以交错发送,然后再根据每个帧头的数据流标识符重新组装。
-
服务端推送
HTTP/2.0 在客户端请求一个资源时,会把相关的资源一起发送给客户端,客户端就不需要再次发起请求了。例如客户端请求 page.html 页面,服务端就把 script.js 和 style.css 等与之相关的资源一起发给客户端。
缓存¶
缓存有3大问题,以及如何解决?¶
缓存雪崩¶
缓存雪崩是指缓存中数据大批量到过期时间,而查询数据量巨大,引起数据库压力过大甚至down机。和缓存击穿不同的是, 缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。
解决办法:
- 缓存过期时间设置成随机
- 热点数据考虑永不过期(定时刷新)
- 使用分布式缓存,防止单点故障缓存全部丢失
缓存穿透¶
缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求,如发起为id为“-1”的数据或id为特别大不存在的数据。这时的用户很可能是攻击者,攻击会导致数据库压力过大。
解决办法:
- 空对象
- 布隆过滤器
缓存击穿¶
缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力
解决办法:
- 热点数据永不过期(后台进程定时刷新)
- 加互斥锁
缓存有哪些淘汰策略?¶
-
先进先出策略 FIFO(First In,First Out)
如果一个数据最先进入缓存中,则应该最早淘汰掉
-
最近最少使用策略 LRU(Least Recently Used)
如果数据最近被访问过,那么将来被访问的几率也更高。对于循环出现的数据,缓存命中不高。实际实现时候一般可采用双向链表,将最近访问过得缓存key放在链表首部,删除尾部的缓存key,再加上hash表来记录key-value,实现快速访问缓存
-
最少使用策略 LFU(Least Frequently Used)
如果一个数据在最近一段时间内使用次数很少,那么在将来一段时间内被使用的可能性也很小。对于交替出现的数据,缓存命中不高
读写屏障是怎么回事?¶
内存屏障分为读屏障(rmb)与写屏障(wmb)。写屏障主要保证在写屏障之前的在Store buffer中的指令都真正的写入了缓存。读屏障主要保证了在读屏障之前所有Invalidate queue中所有的无效化指令都执行。有了读写屏障的配合,那么在不同的核心上,缓存可以得到强同步。
缓存一致性¶
对于读是不存在缓存与数据库不一致的的情况。读的流程:
- 如果我们的数据在缓存里边有,那么就直接取缓存的。
- 如果缓存里没有我们想要的数据,我们会先去查询数据库,然后将数据库查出来的数据写到缓存中。
- 最后将数据返回给请求
对于数据库更新操作, 执行操作时候,两种选择:
- 先操作数据库,再操作缓存
- 先操作缓存,再操作数据库
操作缓存,两种方案选择:
- 更新缓存
- 删除缓存
一般我们都是采取删除缓存缓存策略的,原因:
- 高并发环境下,无论是先操作数据库还是后操作数据库而言,如果加上更新缓存,那就更加容易导致数据库与缓存数据不一致问题。(删除缓存直接和简单很多)
- 如果每次更新了数据库,都要更新缓存【这里指的是频繁更新的场景,这会耗费一定的性能】,倒不如直接删除掉。等再次读取时,缓存里没有,那我到数据库找,在数据库找到再写到缓存里边 (体现懒加载)
先删缓存,再更新数据库
该方案会导致不一致的原因是。同时有一个请求A进行更新操作,另一个请求B进行查询操作。那么会出现如下情形:
先更新数据,再删除缓存:
如果在高并发的场景下,出现数据库与缓存数据不一致的概率特别低,也不是没有:
解决办法: - Cache Aside Pattern
- binlog模式
Nginx¶
Nginx工作模式?¶
Nginx采用master-worker模式,nginx启动成功后,会有一个master进程和至少一个worker进程;master进程负责处理系统信号、加载配置、管理worker进程;worker进程负责处理具体的业务逻辑。
nginx采用了异步非阻塞的工作方式,epoll模型:当有i/o事件产生时,epoll就会告诉进程哪个连接由i/o事件产生,然后进程就会处理这个事件。nginx配置use epoll后,以异步非阻塞的方式工作,能够处理百万计的并发连接。
master-worker模式的优缺点:
-
稳定性高
一个worker进程挂掉后master进程会立即启动一个新的worker进程,保证worker进程数量不变,降低服务中断的概率
-
高性能
Nginx 启动 N 个 worker, 并将 worker 和 cpu 进行绑定,每个 worker 有自己的 epoll 和 定时器,由于没有进程、线程切换开销,性能非常好。配合Linux的cpu亲和性的匹配中,可以充分利用多核cpu的优势,提升性能
-
支持平滑重启
处理信号、配置重新加载等可以做到尽可能不中断服务
Nginx Location 路径匹配规则是怎么样的?¶
对于请求: http://example.com/static/img/logo.jpg
- 如果命中精确匹配,例如:
则优先精确匹配,并终止匹配。
- 如果命中多个前缀匹配,例如:
则记住最长的前缀匹配,即上例中的 /static/img/,并继续匹配
- 如果最长的前缀匹配是优先前缀匹配,即:
- 否则,如果命中多个正则匹配,即:
location /static/ {
}
location /static/img/ {
}
location ~* /static/ {
}
location ~* /static/img/ {
}
则忘记上述 2 中的最长前缀匹配,使用第一个命中的正则匹配,即上例中的 location ~* /static/ ,并终止匹配(命中多个正则匹配,优先使用配置文件中出现次序的第一个)
- 否则,命中上述 2 中记住的最长前缀匹配
Nginx的负载均衡策略有哪些?¶
| 负载均衡策略 | 说明 |
|---|---|
| 轮询(rr) | 负载均衡默认策略 |
| weight | 权重方式,权重越高分配到需要处理的请求越多,此策略比较适合服务器的硬件配置差别比较大的情况。此策略可以与least_conn和ip_hash结合使用。 |
| ip_hash | 依据ip分配方式,基于客户端IP的分配方式,确保了相同的客户端的请求一直发送到相同的服务器,实现会话粘滞目的 |
| least_conn | 最少连接方式,把请求转发给连接数较少的后端服务器,可以达到更好的负载均衡效果 |
| fair(第三方) | 响应时间方式 |
| url_hash(第三方) | 依据URL分配方式 |
nginx基于权重轮询平滑算法是怎么实现的?¶
常规的基于权重的轮询调度算,假定a, b, c三台机器的负载能力分别是4:2:1,则可以给它们分配的权限为4, 2, 1。 这样轮询完一次后,a被调用4次,b被调用2次,c被调用1次。
对于普通的基于权重的轮询算法,可能会产生以下的调度顺序{a, a, a, a, b, b, c}。这样的调度顺序其实并不友好,它会一下子把大压力压到同一台机器上,这样会产生一个机器一下子很忙的情况。 于是乎,就有了平滑的基于权重的轮询算法。
所谓平滑就是调度不会集中压在同一台权重比较高的机器上。这样对所有机器都更加公平。 比如,对于{a:5, b:1, c:1},产生{a, a, b, a, c, a, a}的调度序列就比{c, b, a, a, a, a, a} 更加平滑。
算法逻辑:
算法执行2步,选择出1个当前节点。
- 用上次选择后的权重加上每个节点配置的权重,作为节点当前权重值,第一选择的时候,上次选择后的权重都是0
- 选择当前权重值最大的节点为选中节点,并把它的当前值减去所有节点的权重总和,作为选择后的权重值
例如{a:5, b:1, c:1}三个节点。一开始我们初始化三个节点的当前值为{0, 0, 0}。 选择过程如下表:
| 轮数 | 当前权重 | 选择节点 | 选择后的权重 |
|---|---|---|---|
| 0 | - | - | {0, 0, 0} |
| 1 | {5, 1, 1} | a | {-2, 1, 1} |
| 2 | {3, 2, 2} | a | {-4, 2, 2} |
| 3 | {1, 3, 3} | b | {1, -4, 3} |
| 4 | {6, -3, 4} | a | {-1, -3, 4} |
| 5 | {4, -2, 5} | c | {4, -2, -2} |
| 6 | {9, -1, -1} | a | {2, -1, -1} |
| 7 | {7, 0, 0} | a | {0, 0, 0} |
我们可以发现,a, b, c选择的次数符合5:1:1,而且权重大的不会被连接选择。7轮选择后, 当前值又回到{0, 0, 0},以上操作可以一直循环,一样符合平滑和基于权重。
nginx四层、七层负载均衡的有什么区别?¶
四层就是基于IP+端口的负载均衡,通过虚拟IP+端口接收请求,然后再分配到真实的服务器;nginx修改数据包里面的目标和源IP和端口,然后把数据包发向目标服务器,服务器处理完成后,nginx再做一次修改,返回给请求的客户端。
七层通过虚拟的URL或主机名接收请求,然后再分配到真实的服务器。七层就是基于URL等应用层信息的负载均衡。Nginx需要读取并解析http请求内容,然后根据具体内容(url,参数,cookie,请求头)然后转发到相应的服务器,转发的过程是:建立和目标机器的连接,然后转发请求,收到响应数据在转发给请求客户端。
七层负载均衡是:
# cat /etc/nginx/conf.d/test.conf
upstream phpserver {
server192.168.2.3;
server192.168.2.4;
}
upstream htmlserver {
server192.168.2.1;
server192.168.2.2;
}
# /etc/nginx/nginx.conf
location / {
root /usr/share/nginx/html;
index index.html index.htm;
if ($request_uri ~*\.html$){
proxy_pass http://htmlserver;
}
if ($request_uri~* \.php$){
proxy_pass http://phpserver;
}
}
四层负载均衡:
消息队列¶
为什么要使用消息队列?¶
- 业务解耦
- 异步处理
- 流量削峰
kafka是什么?¶
Kafka是高吞吐低延迟的高并发、高性能的消息中间件,配置良好的Kafka集群甚至可以做到每秒几十万、上百万的超高并发写入。Kafka是**一个分布式消息队列**。Kafka对消息保存时根据Topic进行归类,发送消息者称为Producer,消息接受者称为Consumer,此外kafka集群有多个kafka实例组成,每个实例(server)称为broker。无论是kafka集群,还是consumer都依赖于zookeeper集群保存一些meta信息,来保证系统可用性。
kafka如何做到高可用的?¶
从topic的Partition的副本来看:
上图中只有一个Topic,它有3个Partition。
Kafka 为什么不支持读写分离?¶
自 Kafka 2.4 之后,Kafka 提供了有限度的读写分离,也就是说,Follower 副本能够对外提供读服务。
- 业务场景不适用。读写分离适用于那种读负载很大,而写操作相对不频繁的场景,可 Kafka 不属于这样的场景。
- 同步机制。Kafka 采用 PULL 方式实现 Follower 的同步,因此Follower 与 Leader 存 在不一致性窗口。如果允许读 Follower 副本,就势必要处理消息滞后(Lagging)的问题。
如何解决kafka消息重复消费问题?¶
将消息的唯一标识保存到外部介质中,每次消费时判断是否处理过即可。这个解决办法适合其他消息系统。
Kafka消息是采用Pull模式,还是Push模式?¶
kafka遵循了一种大部分消息系统共同的传统的设计:producer将消息推送(push)到broker,consumer从broker拉取(pull)消息。同redis的bpop命令类似,Kafka有个参数可以让consumer阻塞知道新消息到达,可以防止consumer不断在循环中轮询。
kafka中如何防止消息丢失?¶
Kafka消息发送有两种方式:同步(sync)和异步(async),默认是同步方式,可通过producer.type属性进行配置。Kafka通过配置request.required.acks属性来确认消息的生产:
-
0
表示producer不等待来自broker同步完成的确认继续发送下一条消息;
-
1
表示producer在leader已成功收到的数据并得到确认后发送下一条message,默认状态
-
-1
表示producer在header,follower副本确认接收到数据后才算一次发送完成;
综上所述,有6种消息生产的情况,下面分情况来分析消息丢失的场景:
(1)acks=0,不和Kafka集群进行消息接收确认,则当网络异常、缓冲区满了等情况时,消息可能丢失;
(2)acks=1、同步模式下,只有Leader确认接收成功后但挂掉了,副本没有同步,数据可能丢失;
可见在同步模式下,ack=-1时候,可以防止消息丢失。但这牺牲了吞吐量。
kafka中的 zookeeper 起到什么作用,可以不用zookeeper吗?¶
早期版本的kafka用zk做meta信息存储,consumer的消费状态,group的管理以及 offset的值。新的consumer使用了kafka内部的group coordination协议,也减少了对zookeeper的依赖,但是broker依然依赖于ZK,zookeeper 在kafka中还用来选举controller 和 检测broker是否存活等等。
kafka 为什么那么快?¶
-
Page cache技术
Kafka每次接收到数据都会往磁盘上去写。但并不是直接写入磁盘的,而是写入OS cache上面,然后在写到磁盘
-
顺序读写磁盘
磁盘读写时候,是顺序读写的。此时数据在磁盘上存取代价为O(1)。
-
零拷贝技术
Customer从broker读取数据,采用零拷贝技术。将磁盘文件读到OS内核缓冲区后,直接转到socket buffer进行网络发送。
传统的数据发送需要发送4次上下文切换,采用sendfile系统调用之后,数据直接在内核态交换,系统上下文切换减少为2次。
Kafka中是怎么体现消息顺序性的?¶
kafka每个partition中的消息在写入时都是有序的,消费时,每个partition只能被每一个group中的一个消费者消费,保证了消费时也是有序的。整个topic不保证有序。如果为了保证topic整个有序,那么将partition调整为1。
kafka如何实现延迟队列?¶
kafka基于**时间轮**可以将插入和删除操作的时间复杂度都降为O(1)。
kafka中consumer group 是什么概念?¶
consumer group是Kafka实现单播和广播两种消息模型的手段。同一个topic的数据,会广播给不同的group;同一个group中的worker,只有一个worker能拿到这个数据。换句话说,对于同一个topic,每个group都可以拿到同样的所有数据,但是数据进入group后只能被其中的一个worker消费。group内的worker可以使用多线程或多进程来实现,也可以将进程分散在多台机器上,worker的数量通常不超过partition的数量,且二者最好保持整数倍关系,因为Kafka在设计时假定了一个partition只能被一个worker消费(同一group内)。
Kafka 中位移(offset)的作用?¶
在 Kafka 中,每个 主题分区下的每条消息都被赋予了一个唯一的 ID 数值,用于标识它在分区中的位置。这个 ID 数值,就被称为位移,或者叫偏移量。一旦消息被写入到分区日志,它的位移值将不能 被修改。
阐述下Kafka 中的领导者副本(Leader Replica)和追随者副本 (Follower Replica)的区别?¶
Kafka 副本当前分为领导者副本和追随者副本。只有 Leader 副本才能 对外提供读写服务,响应 Clients 端的请求。Follower 副本只是采用拉(PULL)的方 式,被动地同步 Leader 副本中的数据,并且在 Leader 副本所在的 Broker 宕机后,随时准备应聘 Leader 副本。
自 Kafka 2.4 版本开始,社区通过引入新的 Broker 端参数,允许 Follower 副本有限度地提供读服务。
消息传递语义是什么概念?¶
message delivery semantic 也就是消息传递语义。通用的概念,也就是消息传递过程中消息传递的保证性。分为三种:
-
最多一次(at most once)
消息可能丢失也可能被处理,但最多只会被处理一次。可能丢失,不会重复。
只管发送,不管对方收没收到。
-
至少一次(at least once)
消息不会丢失,但可能被处理多次。可能重复 不会丢失。
发送之后,会等待对方确认之后才会停止发送。
-
精确传递一次(exactly once)
消息被处理且只会被处理一次。不丢失,不重复,就一次。
介绍一下beanstalk?¶
beanstalk是轻量级的,易使用的,C语言实现的消息队列中间件。支持特性有:
-
延迟(delay)
延迟意味着可以定义任务什么时间才开始被消费
-
优先级(priority)
优先级就意味 支持任务插队(数字越小,优先级越高,0的优先级最高)
-
持久化(persistent data)
Beanstalkd 支持定时将文件刷到日志文件里,即使beanstalkd宕机,重启之后仍然可以找回文件
-
任务超时重发(time-to-run)
消费者必须在指定的时间内处理完这个任务,否则就认为消费者处理失败,任务会被重新放到队列,等待消费
Beanstalk由四部分构成:
-
管道(tube)
相当于kafka的Topic概念,是消息的归类。
-
任务(job)
相当于kafka中的消息
-
producer
job的生产者,通过put命令来将一个job放到一个tube中
-
consumer
job的消费者,通过reserve、release、bury、delete命令来获取job或改变job的状态
任务从进入管道到离开管道一共有5个状态(ready,delayed,reserved,buried,delete):
-
生产者将任务放到管道中,任务的状态可以是ready(表示任务已经准备好,随时可以被消费者读取),也可以是delayed(任务在被生产者放入管道时,设置了延迟,比如设置了5s延迟,意味着5s之后,这个任务才会变成ready状态,才可以被消费者读取)
-
消费者消费任务(消费者将处于ready状态的任务读出来后,被读取处理的任务状态变为reserved),可以设置reserved的时间,若在这段时间没有处理完成,那么任务会重新放回消息队列中,再次被别人消费
-
消费者处理完任务后,任务的状态可能是delete(删除,处理成功),可能是buried(预留,意味着先把任务放一边,等待条件成熟还要用),可能是ready,也可能是delayed,需要根据具体业务场景自己进行判断定义
IO¶
Page Cache是怎么回事?¶
Page cache是通过将磁盘中的数据缓存到内存中,从而减少磁盘I/O操作,从而提高性能,这个内存就是Page Cache。Page Cache中被修改的内存页称之为脏页(Dirty Page),脏页在特定的时候被一个叫做pdflush(Page Dirty Flush)的内核线程写入磁盘。
Page Cache中写入方式称为Write back(写回),属于异步方式,即写入内存中即返回,它属于buffered I/O。若写入磁盘之后才返回就是Write Through(写穿),它属于direct I/O。
Page Cache缺点就是会导致数据丢失。为了解决这个问题可以使用WAL技术(Write-Ahead Log),在数据库中一般又称之为redo log。WAL日志是append模式,写入速度是O(1)。
Page Cache与mmap(memory-maped I/O)区别:
mmap是怎么回事?¶
Page Cache的写入时候,需要从应用缓冲区拷贝到内核缓冲区(即Page Cache)。为了避免这样,可以使用mmap技术。
mmap 把文件映射到用户空间里的虚拟地址空间,实现文件和进程虚拟地址空间中一段虚拟地址的一一对映关系。它省去了从内核缓冲区复制到用户空间的过程,进程就可以采用指针的方式读写操作这一段内存(文件 / page cache)。而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作。相反,内核空间对这段区域的修改也直接反映到用户空间,从而可以实现用户态和内核态对此内存区域的共享。
但在真正使用到这些数据前却不会消耗物理内存,也不会有读写磁盘的操作,只有真正使用这些数据时,虚拟内存管理系统 VMS 才根据缺页加载的机制从磁盘加载对应的数据块到内核态的Page Cache。这样的文件读写文件方式少了数据从内核缓存到用户空间的拷贝,效率很高。
mmap有以下特点:
- 文件(page cache)直接映射到用户虚拟地址空间,内核态和用户态共享一片page cache,避免了一次数据拷贝
- 建立mmap之后,并不会立马加载数据到内存,只有真正使用数据时,才会引发缺页异常并加载数据到内存
网络IO模型有哪些?¶
阻塞I/O¶
从发起系统调用(比如read)时候,从等待数据到复制到内核和从内核复制到用户态,全程阻塞。
非阻塞I/O¶
从发起系统调用之后,无需等待,通过轮询方式获取状态,数据准备阶段是fe非阻塞的,而从内核拷贝到用户空间是阻塞的
I/O多路复用¶
监听多个IO对象,当IO对象有数据时候,通知用户进程。
异步I/O¶
发起系统调用后等待数据到达和数据从内核复制到用户态两个io阶段都是非阻塞的
I/O多路复用中select/poll/epoll的区别?¶
select/poll属于一类,传给一组文件描述符数组,返回准备就绪的文件描述符数组,select有大小限制(1024),poll则没有,他们每次都要传文件描述符数组,比较低效,epoll在内核开辟一个空间存放描述符,无须频繁的从用户空间传递给内核
僵尸进程与孤儿进程区别?¶
**僵尸进程**指的子进程比父进程先结束,而父进程又没有回收子进程,释放子进程占用的资源,那么子进程的状态描述符依然保存在系统中。此时子进程将成为一个僵尸进程,解决办法是父进程用wait或者waitpid来获取子进程的状态信息,
**孤儿进程**指的是一个父进程退出, 而它的一个或几个子进程仍然还在运行,那么这些子进程就会变成孤儿进程,孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集的工作
多进程通信方式有哪些?¶
- 共享内存
- 消息队列
- 信号
- 管道
- socket
Protobuf¶
Protobuf编码相比Json的优点有哪些?¶
- 具有强一致性
- 占用空间小,更高效
Protobuf编码存储方式是?¶
Protobuf采用的是Tag - Length - Value,即标识 - 长度 - 字段值。 Tag 整体采用 Varints 编码。
Tag是标识,是将字段编号左移三位之后与字段类型或运算之后产生:
varint是一种变长编码方式,用来字节编码数字,每个字节的高位表示后面的那个字节是否是数字的一部分。
varint缺点是如果用来编码一个负数,一定需要5个byte。因为负数最高位是1,会被当做很大的整数去处理。解决办法是采用zigzag编码,即将负数转换成正数,然后再采用varint编码。
Wire Type = 0时的编码和存储方式¶
对于int32/int64类型的数据(正数),protobuf会使用Varints编码;而对于sint32/sint64类型的数据(负数),protobuf会先使用ZigZag 编码,再使用Varints编码。存储格式为Tag-Value。
Wire Type = 2时的编码和存储方式¶
对于string,bytes和嵌套消息类型的数据,protobuf会使用Length-delimited编码,存储格式为Tag-Length-Value。
Wire Type = 1&5时的编码和存储方式¶
对于大整数类型的数据,protobuf会使用64-bit和32-bit编码方式,存储格式为Tag-Value。 Varints适合处理一定范围内的数字,当数字很大的时候使用Varints编码效率反而很低,因此protobuf定义了64-bit和32-bit两种定长编码类型。
Go¶
互斥锁(Mutex)有哪两种模式?¶
Mutex 可能处于两种操作模式下:正常模式和饥饿模式。
正常模式下,waiter 都是进入先入先出队列,被唤醒的 waiter 并不会直接持有锁,而是要和新来的 goroutine 进行竞争。新来的 goroutine 有先天的优势,它们正在 CPU 中运行,可能它们的数量还不少,所以,在高并发情况下,被唤醒的 waiter 可能比较悲剧地获取不到锁, 这时,它会被插入到队列的前面。如果 waiter 获取不到锁的时间超过阈值 1 毫秒,
那么,这个 Mutex 就进入到了饥饿模式。在饥饿模式下,Mutex 的拥有者将直接把锁交给队列最前面的 waiter。新来的 goroutine 不会尝试获取锁,即使看起来锁没有被持有,它也不会去抢,也不会 spin,它会乖乖地加入到等待队列的尾部。
如果拥有 Mutex 的 waiter 发现下面两种情况的其中之一,它就会把这个 Mutex 转换成正常模式:
-
此 waiter 已经是队列中的最后一个 waiter 了,没有其它的等待锁的 goroutine 了;
-
此 waiter 的等待时间小于 1 毫秒。
饥饿模式是对公平性和性能的一种平衡,它避免了某些 goroutine ⻓时间的等待锁。在饥饿模式下,优先对待的是那些一直在等待的 waiter
Go垃圾清理的三色标记法?¶
三色标记法是传统 Mark-Sweep 的一个改进,它是一个并发的 GC 算法。 原理如下,
- 首先创建三个集合:白、灰、黑。
- 将所有对象放入白色集合中。
- 然后从根节点开始遍历所有对象(注意这里并不递归遍历),把遍历到的对象从白色集合放入灰色集合。
- 之后遍历灰色集合,将灰色对象引用的对象从白色集合放入灰色集合,之后将此灰色对象放入黑色集合 重复 4 直到灰色中无任何对象
- 通过write-barrier检测对象有变化,重复以上操作
- 收集所有白色对象(垃圾)
Golang什么时候会触发GC?¶
- 阈值:默认内存扩大一倍,启动gc
- 定期:默认2min触发一次gc,src/runtime/proc.go:forcegcperiod
- 手动:runtime.gc()
Golang进行GC时候会不会STW?¶
Golang使用的是三色标记法方案,并且支持并行GC,即用户代码何以和GC代码同时运行。具体来讲,Golang GC分为几个阶段:Mark阶段该阶段又分为两个部分:
- Mark Prepare:初始化GC任务,包括开启写屏障(write barrier)和辅助GC(mutator assist),统计root对象的任务数量等,这个过程需要STW。
- GC Drains: 扫描所有root对象,包括全局指针和goroutine(G)栈上的指针(扫描对应G栈时需停止该G),将其加入标记队列(灰色队列),并循环处理灰色队列的对象,直到灰色队列为空。该过程后台并行执行。
- Mark Termination阶段:该阶段主要是完成标记工作,重新扫描(re-scan)全局指针和栈。因为Mark和用户程序是并行的,所以在Mark过程中可能会有新的对象分配和指针赋值,这个时候就需要通过写屏障(write barrier)记录下来,re-scan 再检查一下,这个过程也是会STW的。Sweep: 按照标记结果回收所有的白色对象,该过程后台并行执行。
- Sweep Termination: 对未清扫的span进行清扫, 只有上一轮的GC的清扫工作完成才可以开始新一轮的GC。
总结一下,Golang的GC过程有两次STW:第一次STW会准备根对象的扫描, 启动写屏障(Write Barrier)和辅助GC(mutator assist).第二次STW会重新扫描部分根对象, 禁用写屏障(Write Barrier)和辅助GC(mutator assist).
Go内存分配策略是什么样子的?¶
Golang内存分配管理策略是根据对象大小区分和不同的内存分配层级来分配管理内存。Golang中内存分配管理的对象按照大小可以分为:
| 类别 | 大小 |
|---|---|
| 微对象 tiny object | (0, 16B) |
| 小对象 small object | [16B, 32KB] |
| 大对象 large object | (32KB, +∞) |
Golang中内存管理的层级从最下到最上可以分为:mspan -> mcache -> mcentral -> mheap -> heapArena。golang中对象的内存分配流程如下: - 小于16个字节的对象使用mcache的微对象分配器进行分配内存 - 大小在16个字节到32k字节之间的对象,首先计算出需要使用的span大小规格,然后使用mcache中相同大小规格的mspan分配 - 如果对应的大小规格在mcache中没有可用的mspan,则向mcentral申请 - 如果mcentral中没有可用的mspan,则向mheap申请,并根据BestFit算法找到最合适的mspan。如果申请到的mspan超出申请大小,将会根据需求进行切分,以返回用户所需的页数,剩余的页构成一个新的mspan放回mheap的空闲列表 - 如果mheap中没有可用span,则向操作系统申请一系列新的页(最小 1MB) - 对于大于32K的大对象直接从mheap分配
new和make的区别?¶
传递给 new 函数的是一个类型,不是一个值。返回值是指向这个新分配的零值的指针。
make 的作用是为创建slice,map 或 chan。
内存泄露有哪些场景?¶
-
全局变量,比如全局切片一直被局部变量引用着
-
for + select + time.Afer 进行超时处理时候:
for { select { ... case <-time.After(3 * time.Minute): fmt.Printf("现在是:%d,我脑子进煎鱼了!", time.Now().Unix()) } }因为 for在循环时,就会调用都 select 语句,因此在每次进行 select 时,都会重新初始化一个全新的计时器(Timer)。
-
发送数据到通道时,通道已满
goroutine作为生产者向 channel发送信息,但是没有消费的goroutine,或者消费的goroutine被错误的关闭了。导致channel被打满。
-
从通道接收数据时候,通道为空
作为消费者的goroutine,等待消费channel,但是上游的生产者不存在
排查内存泄露的方法有:使用pprof分析内存使用情况,以及goroutine运行情况
-
向nil通道发送或读取数据时候
内存逃逸有哪些场景?¶
- 闭包造成内存逃逸
- 返回指向栈变量的指针
- 切片变量过大会造成内存逃逸
Go内部包是怎么回事?¶
Go语言1.4版本后增加了 Internal packages 特征用于控制包的导入。如果项目包含多个包,可能有一些公共的函数,这些函数旨在供项目中的其他包使用,但不打算成为项目的公共API的一部分,那么请将其放在名为 internal/ 的目录中,或者放在名为 internal/ 的目录的子目录中。
导入路径包含internal关键字的包,只允许internal的父级目录及父级目录的子包导入,其它包无法导入
Go性能分析手段¶
pprof¶
-
CPU Profiling
CPU分析,按照一定的频率采集所监听的应用程序的CPU使用情况,可确定应用程序在主动消耗 CPU 周期时花费时间的位置。 - Memory Profiling
内存分析,在应用程序堆栈分配时记录跟踪,用于监视当前和历史内存使用情况,检查内存泄漏情况。 - Block Profiling
阻塞分析,记录goroutine阻塞等待同步的位置 - Mutex Profiling
互斥锁分析,报告互斥锁的竞争情况
GODEBUG¶
-
gctrace=1
查看gc情况
-
schedtrace=X
每 X 毫秒输出一行调度器的摘要信息到标准 err 输出中
go test¶
基准测试
go tool trace¶
- goroutine 创建、启动、结束
- gorouting 阻塞、恢复
- 网络阻塞
- 系统调用(syscall)
- GC 事件
perf¶
perf record用来记录一段性能分析数据,并可以根据此生成火焰图。
分布式¶
分布式ID生成方案有哪些?¶
- Redis Incr命令
- Mongodb ObjectID
-
雪花算法
Snowflake算法(雪花算法)是由Twitter提出的一个分布式全局唯一ID生成算法,该算法生成一个64bit大小的长整数。64bit位ID结构如下:
一致性Hash算法¶
一致性Hash算法是为了解决传统Hash算法(比如取余运算)时候,由于添加或删删除节点时候,导致过多数据进行迁移问题而引入的新算法。
假定有几台Redis服务器,假定是N,要存储key-value数据,传统模式是根据hash(key)%N服务器数量来定位出来存放在第几台服务器上面。
一致性Hash算法,会使用2^32个虚拟hash槽位,可以想象成在一个圆环(也叫hash环)上面有0到2^32-1编号的槽位。首先我们确定每台Redis服务器在这个环上槽位,可以用服务器IP或ID或者name进行hash:
槽位 = hash(Redis服务器IP)%2^32
当一个key-value数据过来时候,根据key计算出其在环上槽位,然后沿着环顺时针行走,遇到的第一个服务器就是要存放的服务器。
如上图所示:根据一致性Hash算法,数据A会被定为到Node A上,B被定为到Node B上,C被定为到Node C上,D被定为到Node D上。
一致性Hash算法在服务节点太少时,容易因为节点分部不均匀而造成**数据倾斜**(被缓存的对象大部分集中缓存在某一台服务器上)问题,例如系统中只有两台服务器,其环分布如下:
此时必然造成大量数据集中到Node A上,而只有极少量会定位到Node B上。为了解决这种数据倾斜问题,一致性Hash算法引入了虚拟节点机制,即对每一个服务节点计算多个哈希,每个计算结果位置都放置一个此服务节点,称为虚拟节点。具体做法可以在服务器IP或主机名的后面增加编号来实现。
例如上面的情况,可以为每台服务器计算三个虚拟节点,于是可以分别计算 “Node A#1”、“Node A#2”、“Node A#3”、“Node B#1”、“Node B#2”、“Node B#3”的哈希值,于是形成六个虚拟节点:
什么是分布式一致性?¶
分布式一致性(Distributed Consensus)简单来说就是在多个节点组成系统中各个节点的数据保持一致,并且可以承受某些节点数据不一致或操作失败造成的影响。分布式一致性是分布式系统的基石。
Cap理论是什么?¶
Cap理论中C代表一致性,A代表可用性,P代表分区容忍性。CAP理论下分布式系统在满足P情况下,需要在C和A之间找到平衡。
根据C的情况,数据一致性模型分为:
-
强一致性
新的数据一旦写入,在任意副本任意时刻都能读到新值。强一致性使用的是同步复制,即某节点受到请求之后,必须保证其他所有节点也全部完成同样操作,才算这次请求成功完成。
-
弱一致性
不同副本上的值有新有旧,这需要应用方做更多的工作获取最新值
-
最终一致性
各副本的数据最终将达到一致。一般使用异步复制,这意味有更好的性能,但需要更复杂的状态控制
什么是Raft算法?¶
Raft算法属于最终一致性致性算法实现。在Raft中,每个节点在同一时刻只能处于以下三种状态之一:
- 领导者(Leader)
- 候选者(Candidate)
- 跟随者(Follower)
在Raft中Leader负责所有数据的读写,Follower只能用来接受Leader的Replication log。当一个节点刚开始启动时候默认都是Follwer状态,若在一段时间内,没有收到Leader的心跳,就会开始Leader Election过程:该节点会变成Canidate,将当前term技术加+1,同时投个自己一票,然后向其他节点发起投票请求,若获得集群中半数以上投票,则该节点会成Leader,然后开始向集群中发布心跳。
什么是BASE理论?¶
BASE理论是Basically Available(基本可用),Soft State(软状态)和Eventually Consistent(最终一致性)三个短语的缩写。
其核心思想是:
既是无法做到强一致性(Strong consistency),但每个应用都可以根据自身的业务特点,采用适当的方式来使系统达到最终一致性(Eventual consistency)。
什么是基本可用呢?假设系统,出现了不可预知的故障,但还是能用,相比较正常的系统而言:
- 响应时间上的损失:正常情况下的搜索引擎0.5秒即返回给用户结果,而基本可用的搜索引擎可以在2秒作用返回结果。
- 功能上的损失:在一个电商网站上,正常情况下,用户可以顺利完成每一笔订单。但是到了大促期间,为了保护购物系统的稳定性,部分消费者可能会被引导到一个降级页面。
什么是软状态呢?相对于原子性而言,要求多个节点的数据副本都是一致的,这是一种“硬状态”。
软状态指的是:允许系统中的数据存在中间状态,并认为该状态不影响系统的整体可用性,即允许系统在多个不同节点的数据副本存在数据延时。
上面说软状态,然后不可能一直是软状态,必须有个时间期限。在期限过后,应当保证所有副本保持数据一致性,从而达到数据的最终一致性。这个时间期限取决于网络延时、系统负载、数据复制方案设计等等因素。
如何实现分布式事务?¶
分布式事务分类:
1.刚性事务
- 强一致性:各个业务操作必须在事务结束时全部成功,或者全部失败
- XA模型
- 满足CAP理论的CP
2.柔性事务
- 保证最终一致性,事务结束后在可接受的时间范围内,可能出现短暂的不一致,最终会达成一致性
- 满足CAP理论的AP,满足BASE理论
2PC¶
XA是由X/Open组织提出的分布式事务的架构(或者叫协议)。XA架构主要定义了(全局)事务管理器(Transaction Manager)和(局部)资源管理器(Resource Manager)之间的接口。
2pc实现过程:
-
请求阶段,(commit-request phase,或称表决阶段,voting phase)
在请求阶段,协调者将通知事务参与者准备提交或取消事务,然后进入表决过程。
在表决过程中,参与者将告知协调者自己的决策:同意(事务参与者本地作业执行成功)或取消(本地作业执行故障)。
-
提交阶段(commit phase)
在该阶段,协调者将基于第一个阶段的投票结果进行决策:提交或取消。
当且仅当所有的参与者同意提交事务协调者才通知所有的参与者提交事务,否则协调者将通知所有的参与者取消事务。
参与者在接收到协调者发来的消息后将执行响应的操作。
缺点:
- 同步阻塞,并发能力不高。在没有收到真正提交还是取消事务指令的时候,所有资源管理器锁定当前的资源
- 单点故障:事务的发起、提交还是取消,均是由事务管器管理的,只要事务管理器宕机,那就凉凉了。
TCC¶
分布式事务可以采用TCC来处理分布式事务。TCC核心思想就是针对每个操作,都要注册一个与其对应的确认和补偿(撤销)操作。分为三个阶段:
- Try 阶段:主要是对业务系统做检测(一致性)及资源预留(准隔离性)
- Confirm 阶段:主要是对业务系统做确认提交,Try阶段执行成功并开始执行 Confirm阶段时,默认Confirm阶段是不会出错的。即:只要Try成功,Confirm一定成功。(Confirm 操作满足幂等性。要求具备幂等设计,Confirm 失败后需要进行重试。)
- Cancel 阶段:主要是在业务执行错误,需要回滚的状态下执行的业务取消,预留资源释放。(Cancel 操作满足幂等性)
Try阶段进行资源预留示意图:
国内有一些关于TCC方案介绍的文章中,把TCC分成三种类型:
-
通用型TCC
- 服务需要提供try、confirm、cancel
-
补偿性TCC 业务服务只需要提供 Do 和 Compensate 两个接口
-
异步确保型TCC
主业务服务的直接从业务服务是可靠消息服务,而真正的从业务服务则通过消息服务解耦,作为消息服务的消费端,异步地执行。异步确保型 TCC中业务服务不需要提供try、confirm、cancel三个接口
基于消息队列柔性事务¶
-
操作支付表,然后在事件表里面插入一条数据,状态为new状态,放到数据库,这个(1、2、3)操作都是在一个事务中,因为他们都是一个库
-
定时任务读取事件表,发送队列,发送成功以后,将事件表new的状态改为(published),监听事件表,插入一条数据到事件表
-
定时任务读库是不是published事件表,如果是published事件表,更新订单表,更新事件表为processed,这样就将一个大事务,拆分成几个几个的小事务
Saga¶
confirm 直接执行资源操作,(如:库存服务的减库存,支付服务的扣减账户余额) cancel 回滚资源操作,这个地方的cancel与TCC中的cancel不一样,准确点说应该是回滚,(如:回滚库存服务的减库存操作,回滚支付服务的扣减账户余额的操作)。当然是业务层面实现的回滚,而非数据库事务层面的回滚
Saga正常流程:
Saga异常流程:
Saga对比TCC少了一步try的操作,TCC无论最终事务成功失败都需要与事务参与方交互两次。而Saga在事务成功的情况下只需要与事务参与方交互一次, 如果事务失败,需要交互两次。
saga保证满足以下业务规则:
-
向后恢复
补偿所有已完成的事务,如果任一子事务失败
-
向前恢复
重试失败的事务,假设每个子事务最终都会成功
显然,向前恢复没有必要提供补偿事务,如果你的业务中,子事务(最终)总会成功,或补偿事务难以定义或不可能,向前恢复更符合你的需求
限流算法怎么实现?¶
熔断器工作原理是?¶
什么是Sidecar模式?¶
想象一下假如你有6个微服务相互通信以确定一个包裹的成本。
每个微服务都需要具有可观察性、监控、日志记录、配置、断路器等功能。所有这些功能都是根据一些行业标准的第三方库在每个微服务中实现的。
但再想一想,这不是多余吗?它不会增加应用程序的整体复杂性吗?
这时候可以使用Sidecar模式,将单个服务的所有传入和传出网络流量都流经 Sidecar 代理。 因此,Sidecar 能够管理微服务之间的流量,收集遥测数据并实施相关策略。从某种意义上说,该服务不了解整个网络,只知道附加的 Sidecar 代理。这实际上就是 Sidecar 模式如何工作的本质——将网络依赖性抽象为 Sidecar。
在sidecar上,可以把日志、微服务注册、调用链、限流熔断降级等功能都实现,基于sidecar,抽象出servicemesh。
什么是Service mesh¶
服务网格(Service Mesh)是一种在分布式软件系统中管理服务对服务(service-to-service)通信的技术。服务网格管理东西向类型的网络通信(East-west traffic)。服务网格中有数据平面和控制平面的概念:
- 数据平面的职责是处理网格内部服务之间的通信,并负责服务发现、负载均衡、流量管理、健康检查等功能。
- 控制平面的职责是管理和配置 Sidecar 代理以实施策略并收集遥测
Consul架构是怎么样的?¶
-
Agent是Consul集群中的守护进程。它的生命周期从启动Consul agent开始。Agent可以以client或是server模式运行
-
Client:Client是转发所有RPC请求到Server的Agent。Client相对来说是无状态的,它的唯一后台活动是参与LAN gossip pool。它的资源开销很小,只消耗少量的网络带宽
-
Server:Server是负责参与Raft quorum,维护集群状态,响应RPC查询,和其他datacenter交换WAN gossip信息并转发查询到leader或是远程datacenter的Agent
-
Gossip:Consul是建立在处于多种目的提供了完整gossip 协议的Serf之上的。Serf提供了成员管理、错误检测、时间传播等特性。我们只需要知道gossip会触发随机的点到点通信,主要基于UDP协议
-
LAN Gossip:位于相同本地网络区域或是datacenter的节点之间的LAN gossip pool
-
WAN Gossip:只包含server的WAN gossip pool。这些server主要存在于不同的datacenter中,并且通常通过internet或广域网进行通信
架构设计中,你会考虑的点?¶
- 可靠性
- 非核心业务降级处理
- 高并发业务支持限流操作
- 应用鉴权授权机制,防止未经授权的访问和滥用
- 多机部署,实现故障转移
- 可伸缩性
- 无状态应用,可多机分布式部署
- 可维护性
- 尽量不引入新的技术栈
- 可观察性
- prometheus exporter 上传metric指标
- 告警功能
- 应用提供健康检查接口,拨测系统健康拨测
ElasticSearch¶
什么是ElasticSearch?¶
Elasticsearch是基于Apace Lunence构建的开源,分布式,具有高可用性和高拓展性的全文检索引擎。Elasticsearch具有开箱即用的特性,提供RESTful接口,是面向文档的数据库,文档存储格式为JSON,可以水平扩展至数以百计的服务器存储来实现处理PB级别的数据。
ES有哪些节点类型?¶
一个节点就是一个Elasticsearch的实例,每个节点需要显示指定节点名称,可以通过配置文件配置,或者启动时候-E node.name=node1指定
节点类型¶
每个节点在集群承担承担不同的角色,也可以称为节点类型。
候选主节点(Master-eligible nodes)和主节点(Master Node)
- 每个节点启动之后,默认就是一个Master eligible节点,Master-eligible节点可以参加选主流程,成为Master节点
- 当第一个节点启动时候,它会将自己选举成为Master节点
- 在每个节点上都保存了集群的状态信息,但**只有Master节点才能修改集群的状态信息**。集群状态(Cluster State)中必要信息包含
- 所有节点的信息
- 所有的索引,以及其Mapping与Setting信息
- 分片的路由信息
数据节点(Data Node)和协调节点(Coordinating Node)和Ingest节点
- Data Node
- 用于保存数据的节点。负责保存分片的数据,在数据拓展上起到至关重要的作用
- Coorination Node
- 负责接受Client的请求,将请求分发到合适的节点,最终把结果汇集到一起
- 每个节点默认都起到Cooridinating Node的职责,这就意味着如果一个node,将node.master,node.data,node.ingest全部设置为false,那么它就是一个纯粹的coordinating Node node,仅仅用于接收客户端的请求,同时进行请求的转发和合并
- Ingest节点
- 用于预处理,可以运行pipeline脚本,用来对document写入索引文件之前进行预处理的
在生产环境部署上可以部署独立(dedicate)的 Ingest Node 和 Coordinate node,在前端的Load Balance前面增加转发规则把读分发到coording node,写分发到 ingest node。 如果集群负载不高,可以配置一些节点同时具备coording和ingest的能力。然后将读写全部路由到这些节点。不仅配置简单,还节约硬件成本
其他类型节点
-
冷热节点(Hot & Warm Node)
- 不同硬件配置的Data Node,用来实现Hot & Warm架构,降低集群部署的成本。通过设置节点属性来实现
-
机器学习节点(Machine Learning Node)
- 负责跑机器学习的Job,用来异常检测
配置原则:
- 开发环境一个节点可以承担多种角色,节省服务器资源
- 生产环境中,应该设置单一的角色的节点,即dedicated node
| 节点类型 | 配置参数 | 默认值 |
|---|---|---|
| 候选主节点 | node.master | true |
| 数据节点 | node.data | true |
| ingest节点 | node.ingest | true |
| 协调节点 | 无 | 每个节点默认都是协调节点 |
| 机器学习节点 | node.ml | true |
为什么说ES是准实时的?¶
Document首先写入到Indexing buffer中,当 buffer 中的数据每隔index.refresh_interval秒(或者Indexing buffer满了)缓存 refresh 到filesystem cache 中时,此时文档是可以检索到了
怎么解决ES深度分页问题?¶
深度分页指的是假设我们请求第 1000 页—结果从 10001 到 10010 。所有都以相同的方式工作除了每个分片不得不产生前10010个结果以外。 然后协调节点对全部 50050 个结果排序最后丢弃掉这些结果中的 50040 个结果。在分布式系统中,对结果排序的成本随分页的深度成指数上升。
解决办法就是业务上面避免。
doc values为了解决什么?¶
doc_values是为了解决排序和聚合问题。doc_values不适合text类型字段,对于text类型字段需要使用Fielddata
PUT /myindex/_mapping/doc
{
"properties": {
"myfield": {
"type": "text",
"fields":{
"keyword":{
"type":"keyword",
"ignore_above":256
}
}
}
}
}
上面myfield是不可以聚合的,但是myfield.keyword是可以聚合的
ES中副本分片的目的是做什么?¶
-
副本分片的主要目的就是为了故障转移,如果持有主分片的节点挂掉了,一个副本分片就会晋升为主分片的角色。
-
副本分片可以服务于读请求,可以通过增加副本的数目来提升查询性能
怎样在不停机情况下,进行索引重建?¶
索引别名
ES数据建模有哪些模式?¶
加入我们正在根据用户名称,搜索其博客文章
应用层联接¶
根据user索引搜索到用户id,然后根据id去搜索文章索引blogpost
PUT /my_index/user/1
{
"name": "John Smith",
"email": "john@smith.com",
"dob": "1970/10/24"
}
PUT /my_index/blogpost/2
{
"title": "Relationships",
"body": "It's complicated...",
"user": 1
}
反范式设计¶
用于1对多模式下,将1的信息,存放在N这一边。下面就是把用户信息存放一份在blogpost这里面
PUT /my_index/user/1
{
"name": "John Smith",
"email": "john@smith.com",
"dob": "1970/10/24"
}
PUT /my_index/blogpost/2
{
"title": "Relationships",
"body": "It's complicated...",
"user": {
"id": 1,
"name": "John Smith"
}
}
嵌套对象¶
假定们可以将一篇博客文章的评论以一个 comments 数组的形式和博客文章放在一起:
PUT /my_index/blogpost/1
{
"title": "Nest eggs",
"body": "Making your money work...",
"tags": [ "cash", "shares" ],
"comments": [
{
"name": "John Smith",
"comment": "Great article",
"age": 28,
"stars": 4,
"date": "2014-09-01"
},
{
"name": "Alice White",
"comment": "More like this please",
"age": 31,
"stars": 5,
"date": "2014-10-22"
}
]
}
我们想要搜索到评论者是Alice,且年龄是28岁的评论,搜索语句如下:
GET /_search
{
"query": {
"bool": {
"must": [
{ "match": { "name": "Alice" }},
{ "match": { "age": 28 }}
]
}
}
}
搜索结果却能够搜索到记录。这是不符合预期的。
这是因为对象数组中JSON 格式的文档被处理成如下的扁平式键值对的结构,comments对象中字段失去关联:
{
"title": [ eggs, nest ],
"body": [ making, money, work, your ],
"tags": [ cash, shares ],
"comments.name": [ alice, john, smith, white ],
"comments.comment": [ article, great, like, more, please, this ],
"comments.age": [ 28, 31 ],
"comments.stars": [ 4, 5 ],
"comments.date": [ 2014-09-01, 2014-10-22 ]
}
这时候我们可以使用nested object,来保证子对象关系未打散:
{
"comments.name": [ john, smith ],
"comments.comment": [ article, great ],
"comments.age": [ 28 ],
"comments.stars": [ 4 ],
"comments.date": [ 2014-09-01 ]
}
{
"comments.name": [ alice, white ],
"comments.comment": [ like, more, please, this ],
"comments.age": [ 31 ],
"comments.stars": [ 5 ],
"comments.date": [ 2014-10-22 ]
}
{
"title": [ eggs, nest ],
"body": [ making, money, work, your ],
"tags": [ cash, shares ]
}
PUT /my_index
{
"mappings": {
"blogpost": {
"properties": {
"comments": {
"type": "nested",
"properties": {
"name": { "type": "string" },
"comment": { "type": "string" },
"age": { "type": "short" },
"stars": { "type": "short" },
"date": { "type": "date" }
}
}
}
}
}
}
父子文档¶
在 nested objects 文档中,所有对象都是在同一个文档中,而在父-子关系文档中,父对象和子对象都是完全独立的文档。
父-子关系的主要优势有:
- 更新父文档时,不会重新索引子文档。
- 创建,修改或删除子文档时,不会影响父文档或其他子文档。这一点在这种场景下尤其有用:子文档数量较多,并且子文档创建和修改的频率高时。
- 子文档可以作为搜索结果独立返回。
ES索引生命周期管理是怎么回事?¶
ES索引生命周期管理分为4个阶段:hot、warm、cold、delete,其中hot主要负责对索引进行rollover操作,warm、cold、delete分别对rollover后的数据进一步处理。
| phases | desc |
|---|---|
| hot | 索引更新和查询很活跃 |
| warm | 索引不再更新,但仍然有查询 |
| cold | 索引不再更新,只有很少的查询,而且查询速度也很慢 |
| delete | 索引不需要了,可以安全的删除 |
操作¶
timing¶
ILM各个阶段的action几乎都需要用到定时器,例如下面这个操作:
curl -X PUT "localhost:9200/_ilm/policy/my_policy" -H 'Content-Type: application/json' -d'
{
"policy": {
"phases": {
"warm": {
"min_age": "1d",
"actions": {
"allocate": {
"number_of_replicas": 1
}
}
},
"delete": {
"min_age": "30d",
"actions": {
"delete": {}
}
}
}
}
}
'
# 应用到模板上
PUT _index_template/my_template
{
"index_patterns": ["test-*"],
"template": {
"settings": {
"number_of_shards": 1,
"number_of_replicas": 1,
"index.lifecycle.name": "my_policy",
"index.lifecycle.rollover_alias": "test-alias"
}
}
}
上述warm阶段通过min_age配置了1d,意思是索引从创建至少需要经历1天的时间才会被移入到warm阶段,另一个delete阶段min_age配置了30d,意思是索引在创建后的30天会被删除;通常情况下warm、cold、delete的起始时间是从索引创建开始算起的,但是如果配置了hot,那么后面phrase配置的时间应该大于rollover的时间。
Hot Rollover¶
curl -X PUT "localhost:9200/_ilm/policy/datastream_policy" -H 'Content-Type: application/json' -d'
{
"policy": {
"phases": {
"hot": {
"actions": {
"rollover": {
"max_size": "50GB",
"max_age": "30d"
}
}
}
}
}
'
我们定义了一个策略,策略中使用了hot rollover action,当引用该策略的索引满足rollover中任一一个条件时就会触发滚动操作,生成新的索引,新索引的格式是 ^.*-\d+$ (如,index_name-000001)
Warm Allocate¶
allocate action主要有两个操作,1、转移数据到warm节点;2、修改索引副本数。
PUT _ilm/policy/my_policy
{
"policy": {
"phases": {
"warm": {
"actions": {
"allocate" : {
"number_of_replicas": 0,
"include" : {
"box_type": "cold,warm"
}
}
}
}
}
}
}
其中include配置的标签需要和elasticsearch.yml中配置的标签名一致,allocate支持的参数有:
参数 | 描述 number_of_replicas | 分配后索引保持的分片数 include | 至少满足其中一个标签 exclude | 排除包含这些标签的服务器 require | 需要同时满足所有配置的标签
Warm Read-Only¶
配置索引为只读模式
curl -X PUT "localhost:9200/_ilm/policy/my_policy" -H 'Content-Type: application/json' -d'
{
"policy": {
"phases": {
"warm": {
"actions": {
"readonly" : { }
}
}
}
}
}
'
Warm Force-Merge¶
指定索引合并后保留的segment数,过多的 segment 对查询性能有影响,为了充分合并数据,建议设置为 max_num_segments = 1
curl -X PUT "localhost:9200/_ilm/policy/my_policy" -H 'Content-Type: application/json' -d'
{
"policy": {
"phases": {
"warm": {
"actions": {
"forcemerge" : {
"max_num_segments": 1
}
}
}
}
}
}
'
需要注意的是,设置forcemerge action后索引会被修改为只读模式
Warm Shrink¶
通过shrink action可以降低索引的分片数量,同样执行该action操作后,索引会被修改为只读模式,同时索引名也会发生变化,如原来索引名称是“logs”,执行后的名称会多一个shrink-前缀,即“shrink-logs”。
curl -X PUT "localhost:9200/_ilm/policy/my_policy" -H 'Content-Type: application/json' -d'
{
"policy": {
"phases": {
"warm": {
"actions": {
"shrink" : {
"number_of_shards": 1
}
}
}
}
}
}
'
Cold Freeze¶
冻结索引意思就是关闭索引。
docker¶
Docker四种网络模式?¶
| Docker网络模式 | 配置 | 说明 |
|---|---|---|
| host模式 | –net=host | 容器和宿主机共享Network namespace。该模式下容器是不会拥有自己的ip地址,而是**使用宿主机的ip地址和端口**。这种模式的好处就是**网络性能比桥接模式的好**。缺点就是会占用宿主机的端口,网络的隔离性不太好。 |
| container模式 | –net=container:NAME_or_ID | 容器和另外一个容器共享Network namespace。kubernetes中的pod就是多个容器共享一个Network namespace。 |
| none模式 | –net=none | 容器有独立的Network namespace,但并没有对其进行任何网络设置,如分配veth pair 和网桥连接,配置IP等。没有IP地址,无法连接外网,一般用于测试 |
| bridge模式 | –net=bridge | (默认为该模式) |
Docker的bridge网络是如何工作的,以及如何进行内外网络通信的?¶
Docker容器创建时候默认会连接到docker0这个虚拟网桥(172.17.0.1),并从docker0子网中分配一个IP给容器使用,并设置docker0的IP地址为容器的默认网关。在主机上创建一对虚拟网卡veth pair设备,Docker将veth pair设备的一端放在新创建的容器中,并命名为eth0(容器的网卡),另一端放在主机中,以vethxxx这样类似的名字命名,并将这个网络设备加入到docker0网桥中。可以通过brctl show命令查看。
如何与外部通信?
-
busybox 发送 ping 包:172.17.0.2 > www.baidu.com。
docker0 收到包,发现是发送到外网的,交给 NAT 处理。
-
NAT 将源地址换成 enp0s3 的 IP:10.0.2.15 > www.bing.com。
-
ping 包从 enp0s3 发送出去,到达 www.bing.com。
外部世界如何访问容器?
一句话就是**端口映射**。
-
docker-proxy 监听 host 的 32773 端口。
-
当 curl 访问 10.0.2.15:32773 时,docker-proxy转发给容器 172.17.0.2:80。
-
httpd 容器响应请求并返回结果。
资料¶
- nginx平滑的基于权重轮询算法分析
- elasticsearch中 refresh 和flush区别
- 一文带你彻底弄懂ES中的doc_values和fielddata
- Docker 网络之bridge如何与外部通信
- Docker 网络之bridge外部世界如何访问容器
- MySQL Gap Lock问题
- Mysql给已存在的表创建分区
- protobuf编码和存储方式详解
- https://cloud.tencent.com/developer/article/1478198
- Go性能分析工具工具和手段
- Redis中hash、set、zset有多牛?从底层告诉你数据结构原理
- Redis底层数据结构之 zset
- redis中zset底层实现原理


















































































































































































































































































